diff --git a/README.md b/README.md index a92133c..9473ce2 100644 --- a/README.md +++ b/README.md @@ -21,18 +21,19 @@ The calculator and About pages are placeholders. Adding items from the catalog t ## What to review -| Review | What to look at | -| -------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | -| [Running application](http://it2810-29.idi.ntnu.no/project2) | Search, load more, element details, and the Your gymnasts test login | -| [ElementsPage tests](src/components/elements/ElementsPage.test.tsx) | Search, result paging, URL state, loading, and error behavior | -| [Element detail tests](src/components/elements/ElementDetailPage.test.tsx) | Catalog detail rendering and error/loading states | -| [Gymnast page tests](src/components/gymnasts/YourGymnastsPage.test.tsx) | Test login, loading, profile creation, and error handling | -| [Backend catalog tests](backend/src/catalog/search.test.ts) | Search, filters, sorting, pagination, and count limits | -| [Backend server tests](backend/src/server/server.test.ts) | GraphQL API behavior; database-backed tests require a migrated and seeded database | -| [docs/API.md](docs/API.md) | API design, schema, search, pagination, authentication, and errors | -| [docs/AI_USAGE.md](docs/AI_USAGE.md) | How AI was used and how the work was reviewed | -| [docs/ACCESSIBILITY.md](docs/ACCESSIBILITY.md) | Accessibility choices, testing, and known open points | -| [docs/SUSTAINABILITY.md](docs/SUSTAINABILITY.md) | Sustainable design choices, measurements, and what remains unmeasured | +| Review | What to look at | +| ------------------------------------------------------------------ | ---------------------------------------------------------------------------------- | +| [Running application](http://it2810-29.idi.ntnu.no/project2) | Search, load more, element details, and the Your gymnasts test login | +| [ElementsPage tests](src/tests/pages/ElementsPage.test.tsx) | Search, result paging, URL state, loading, and error behavior | +| [Element detail tests](src/tests/pages/ElementDetailPage.test.tsx) | Catalog detail rendering and error/loading states | +| [Gymnast page tests](src/tests/pages/YourGymnastsPage.test.tsx) | Test login, loading, profile creation, and error handling | +| [Backend catalog tests](backend/src/catalog/search.test.ts) | Search, filters, sorting, pagination, and count limits | +| [Backend server tests](backend/src/server/server.test.ts) | GraphQL API behavior; database-backed tests require a migrated and seeded database | +| [docs/API.md](docs/API.md) | API design, schema, search, pagination, authentication, and errors | +| [docs/AI_USAGE.md](docs/AI_USAGE.md) | How AI was used and how the work was reviewed | +| [docs/ACCESSIBILITY.md](docs/ACCESSIBILITY.md) | Accessibility choices, testing, and known open points | +| [docs/STATE_MANAGEMENT.md](docs/STATE_MANAGEMENT.md) | Where each kind of state lives, and the shared parts | +| [docs/SUSTAINABILITY.md](docs/SUSTAINABILITY.md) | Sustainable design choices, measurements, and what remains unmeasured | ### How to try it @@ -140,7 +141,7 @@ An icon without text needs a label for screen readers, for example ` + + + + + ) +} diff --git a/src/components/ErrorMessage.tsx b/src/components/ErrorMessage.tsx new file mode 100644 index 0000000..8d12675 --- /dev/null +++ b/src/components/ErrorMessage.tsx @@ -0,0 +1,36 @@ +import type { ReactNode } from 'react' +import { TriangleAlert } from 'lucide-react' + +/** + * One error message, the first of the three layers (`ErrorMessage`, `ErrorPanel`, + * `ErrorPage`) that every error of the app is shown with, so that they look and are read the + * same. It is a `role="alert"`, so a screen reader reads it when it appears, and only the + * text is read: a button beside it is not part of the alert. The meaning is in the icon and + * the words and not in the color alone (WCAG 1.4.1); the icon is the red (3:1 on the card in + * both themes, checked in gymnastTheme.test.ts), and the text is the text color, which can be + * read on the page and on a card. Give it an `id` when a field points at it with + * `aria-describedby`. + */ +export default function ErrorMessage({ + children, + id, + className = '', +}: { + children: ReactNode + id?: string + className?: string +}) { + return ( + + ) +} diff --git a/src/components/ErrorPage.tsx b/src/components/ErrorPage.tsx new file mode 100644 index 0000000..915a988 --- /dev/null +++ b/src/components/ErrorPage.tsx @@ -0,0 +1,38 @@ +import type { ReactNode } from 'react' +import ErrorPanel from '@/components/ErrorPanel' +import PageTitle from '@/components/PageTitle' + +/** + * The third layer: a whole page that says what went wrong. It has the title of the page (the + * tab, the history, a screen reader), the `h1`, and an `ErrorPanel`. `className` is the + * classes of the `
`, so the page keeps the look of its other states. + */ +export default function ErrorPage({ + title, + message, + retryable, + onRetry, + action, + className = '', +}: { + title: string + message: string + retryable?: boolean + onRetry?: () => void + action?: ReactNode + className?: string +}) { + return ( +
+ {title} +

{title}

+ +
+ ) +} diff --git a/src/components/ErrorPanel.tsx b/src/components/ErrorPanel.tsx new file mode 100644 index 0000000..6cfa4a8 --- /dev/null +++ b/src/components/ErrorPanel.tsx @@ -0,0 +1,49 @@ +import type { ReactNode } from 'react' +import ErrorMessage from '@/components/ErrorMessage' +import { Button } from '@/components/ui/button' +import { PILL } from '@/components/ui/pill' + +/** + * The second layer: an error message in a card, with a way on. "Try again" is shown when the + * error can be tried again (`retryable`, from `describeError`) and the caller can do it + * (`onRetry`); `action` is anything else that helps, such as a link to log in. Use it for a + * part of a page that could not be loaded. The message is the only alert: the buttons are not + * read out as a part of it. + */ +export default function ErrorPanel({ + message, + retryable = false, + onRetry, + action, + className = '', +}: { + message: string + retryable?: boolean + onRetry?: () => void + action?: ReactNode + className?: string +}) { + const canRetry = retryable && onRetry !== undefined + return ( +
+ {message} + {(canRetry || action) && ( +
+ {canRetry && ( + + )} + {action} +
+ )} +
+ ) +} diff --git a/src/components/ProtectedRoute.tsx b/src/components/ProtectedRoute.tsx index c4b2876..94f735d 100644 --- a/src/components/ProtectedRoute.tsx +++ b/src/components/ProtectedRoute.tsx @@ -1,10 +1,10 @@ -import { Navigate, Outlet, useLocation } from 'react-router-dom'; -import { useAuth } from '@/components/auth/useAuth'; -import PageTitle from '@/components/PageTitle'; +import { Navigate, Outlet, useLocation } from 'react-router-dom' +import { useAuth } from '@/components/auth/useAuth' +import PageTitle from '@/components/PageTitle' interface ProtectedRouteProps { /** Where an anonymous visitor is sent. The page they wanted is kept, so the login can send them back. */ - redirectPath?: string; + redirectPath?: string } /** @@ -16,8 +16,8 @@ interface ProtectedRouteProps { export const ProtectedRoute = ({ redirectPath = '/login', }: ProtectedRouteProps) => { - const { status } = useAuth(); - const location = useLocation(); + const { status } = useAuth() + const location = useLocation() if (status === 'loading') { return ( @@ -25,12 +25,12 @@ export const ProtectedRoute = ({ Checking your login

Checking your login…

- ); + ) } if (status === 'anonymous') { - return ; + return } - return ; -}; + return +} diff --git a/src/components/about/AboutSection.tsx b/src/components/about/AboutSection.tsx new file mode 100644 index 0000000..69a918f --- /dev/null +++ b/src/components/about/AboutSection.tsx @@ -0,0 +1,22 @@ +import { useId, type ReactNode } from 'react' + +/** + * One part of the About page: a heading (h2) and plain text under it. The `
` is named + * by its heading, so a screen reader can list the parts and jump between them. + */ +export default function AboutSection({ + title, + children, +}: { + title: string + children: ReactNode +}) { + const headingId = useId() + + return ( +
+

{title}

+ {children} +
+ ) +} diff --git a/src/components/elements/AddToGymnast.tsx b/src/components/elements/AddToGymnast.tsx new file mode 100644 index 0000000..2e732c7 --- /dev/null +++ b/src/components/elements/AddToGymnast.tsx @@ -0,0 +1,146 @@ +import { Menu } from '@base-ui/react/menu' +import { LockKeyhole, Plus } from 'lucide-react' +import { Link, useLocation } from 'react-router-dom' +import { useAuth } from '@/components/auth/useAuth' +import AddButton from '@/components/gymnasts/AddButton' +import { Button } from '@/components/ui/button' +import { MENU_ITEM, MENU_LABEL, MENU_POPUP } from '@/components/ui/menuStyles' +import { PILL } from '@/components/ui/pill' +import { describeError } from '@/lib/apollo/errors' +import type { ElementDetail } from '@/lib/catalog' +import { useAddElementToGymnast } from '@/components/elements/useAddElementToGymnast' + +/** The button when nothing can be added: a removed element, or the answer about the login is on its way. */ +function Unavailable({ describedBy }: { describedBy?: string }) { + return ( + + ) +} + +/** The gymnasts of the coach in a menu, one choice for each gymnast. */ +function GymnastMenu({ + element, + onAdded, + onFailed, +}: { + element: ElementDetail + onAdded: (message: string) => void + onFailed: (message: string) => void +}) { + const { list, loading, error, adding, add } = useAddElementToGymnast( + element, + onAdded, + onFailed, + ) + + return ( + + + } + > + Add to gymnast + + + + + + + Add {element.code} to + + {!list && loading ? ( +

+ Loading your gymnasts… +

+ ) : !list ? ( +

+ {describeError(error).message} +

+ ) : list.length === 0 ? ( + <> +

You have no gymnasts yet.

+ } + className={MENU_ITEM} + > + Go to your gymnasts + + + ) : ( + list.map((gymnast) => ( + void add(gymnast)} + className={MENU_ITEM} + > + {gymnast.name} + + )) + )} +
+
+
+
+
+ ) +} + +/** + * "Add to gymnast" on the page of an element. What it is depends on the login, because only a + * logged-in coach has gymnasts: + * - logged in: a menu with the gymnasts of the coach; the choice adds the element to that list; + * - not logged in: a link to the login page, with a lock, which sends the visitor back to this + * page after the login. It is not disabled: a disabled button cannot be reached by the + * keyboard, says nothing about why, and leaves the visitor without a way on; + * - a removed element (the API refuses it) or an answer about the login that is not here yet: + * a disabled button. `describedBy` is the reason, which a screen reader reads with it. + */ +export default function AddToGymnast({ + element, + describedBy, + onAdded, + onFailed, +}: { + element: ElementDetail + describedBy?: string + onAdded: (message: string) => void + onFailed: (message: string) => void +}) { + const auth = useAuth() + const location = useLocation() + + if (element.removed || auth.status === 'loading') { + return + } + if (auth.status === 'anonymous') { + return ( + + Add to gymnast + (log in first) + + ) + } + return +} diff --git a/src/components/elements/ElementActions.test.tsx b/src/components/elements/ElementActions.test.tsx deleted file mode 100644 index cf4306f..0000000 --- a/src/components/elements/ElementActions.test.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { renderToString } from 'react-dom/server' -import { describe, expect, it } from 'vitest' -import type { ElementDetail } from '@/lib/catalog' -import ElementActions from './ElementActions' - -const element: ElementDetail = { - id: 'UB-2.305-a', - code: '2.305', - name: null, - description: 'Clear hip circle to hstd', - value: 0.3, - isVault: false, - apparatusId: 'UB', - apparatusName: 'Uneven bars', - elementGroupName: 'Casts and clear hip circles', - elementGroupNumber: 2, - imageUrl: 'http://localhost:3001/images/UB/UB-2.305-a.webp', - removed: false, - variants: [], -} - -const disabledButtons = (detail: ElementDetail) => - renderToString().match( - /]*disabled=""/g, - )?.length ?? 0 - -describe('ElementActions', () => { - it('turns off both buttons for a removed element, since the API refuses it', () => { - expect(disabledButtons(element)).toBe(0) - expect(disabledButtons({ ...element, removed: true })).toBe(2) - }) - - it('links both disabled buttons to the reason, so a screen reader reads it', () => { - const markup = renderToString( - , - ) - const reasonId = /

]*>A removed element/.exec(markup)?.[1] - expect(reasonId).toBeTruthy() - expect( - markup.match(new RegExp(`aria-describedby="${reasonId}"`, 'g')), - ).toHaveLength(2) - }) -}) diff --git a/src/components/elements/ElementActions.tsx b/src/components/elements/ElementActions.tsx index e094740..1002314 100644 --- a/src/components/elements/ElementActions.tsx +++ b/src/components/elements/ElementActions.tsx @@ -1,6 +1,9 @@ import { useId, useState } from 'react' -import { LockKeyhole, Plus } from 'lucide-react' +import { Plus } from 'lucide-react' +import AddToGymnast from '@/components/elements/AddToGymnast' +import ErrorMessage from '@/components/ErrorMessage' import { Button } from '@/components/ui/button' +import { PILL } from '@/components/ui/pill' import type { ElementDetail } from '@/lib/catalog' export default function ElementActions({ @@ -9,6 +12,7 @@ export default function ElementActions({ element: ElementDetail }) { const [status, setStatus] = useState('') + const [error, setError] = useState('') const reasonId = useId() // A screen reader reads the reason with each disabled button. const describedBy = element.removed ? reasonId : undefined @@ -20,28 +24,30 @@ export default function ElementActions({ > - + { + setError('') + setStatus(message) + }} + onFailed={(message) => { + setStatus('') + setError(message) + }} + /> {element.removed && ( -

+

A removed element cannot be added to a routine or a gymnast.

)} @@ -49,10 +55,11 @@ export default function ElementActions({ role="status" aria-live="polite" aria-atomic="true" - className="text-xs text-(--text-color-dark)" + className="text-xs text-(--text-color)" > {status}

+ {error && {error}} ) } diff --git a/src/components/elements/ElementDetailHeader.tsx b/src/components/elements/ElementDetailHeader.tsx index 70eabdb..bac14d7 100644 --- a/src/components/elements/ElementDetailHeader.tsx +++ b/src/components/elements/ElementDetailHeader.tsx @@ -15,13 +15,13 @@ export default function ElementDetailHeader({ type="button" onClick={() => navigate(-1)} aria-label="Go back to previous page" - className="inline-flex items-center gap-2 rounded-md py-2 text-sm font-medium text-(--text-color-dark) underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring" + className="inline-flex items-center gap-2 rounded-md py-2 text-sm font-medium text-(--text-color) underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--text-color)" >
-

+

{displayCode(element, element.variants.length > 0)} {element.name && ( <> @@ -31,7 +31,7 @@ export default function ElementDetailHeader({ )}

{element.description && ( -

+

{element.description}

)} diff --git a/src/components/elements/ElementDiagram.tsx b/src/components/elements/ElementDiagram.tsx index 6e56b6c..5bd9bc3 100644 --- a/src/components/elements/ElementDiagram.tsx +++ b/src/components/elements/ElementDiagram.tsx @@ -1,5 +1,6 @@ import { Card, CardContent } from '@/components/ui/card' import { displayCode, type ElementDetail } from '@/lib/catalog' +import { imageUrlForApp } from '@/lib/imageUrl' export default function ElementDiagram({ element, @@ -8,10 +9,11 @@ export default function ElementDiagram({ }) { return (
- + {/* The drawings are black lines, so they keep a white ground in both themes. */} + {`Illustration 0)}${element.name ? `: ${element.name}` : ''}`} className="block h-auto max-h-[75svh] w-full object-contain" /> diff --git a/src/components/elements/ElementMetadataCard.tsx b/src/components/elements/ElementMetadataCard.tsx index 773ec1f..5c7c2e2 100644 --- a/src/components/elements/ElementMetadataCard.tsx +++ b/src/components/elements/ElementMetadataCard.tsx @@ -1,7 +1,6 @@ import type { ReactNode } from 'react' import { Card, CardContent } from '@/components/ui/card' import DifficultyBadge from '@/components/ui/DifficultyBadge' -import ElementValueBadge from '@/components/ui/ElementValueBadge' import type { ElementDetail } from '@/lib/catalog' import { getDifficultyLetter } from '@/lib/difficulty' import { groupLabel } from '@/lib/elementList' @@ -15,10 +14,10 @@ function MetadataItem({ }) { return (
-
+
{label}
-
+
{children}
@@ -30,21 +29,18 @@ export default function ElementMetadataCard({ }: { element: ElementDetail }) { + // An element has a letter A-J; a vault has its D-value, which the badge writes as a number. const difficulty = element.isVault - ? undefined - : getDifficultyLetter(element.value) + ? element.value + : (getDifficultyLetter(element.value) ?? element.value) return (
- +
- {element.isVault || !difficulty ? ( - - ) : ( - - )} + {element.apparatusName} diff --git a/src/components/elements/ResultsTable.tsx b/src/components/elements/ResultsTable.tsx index ab91b33..8ff38d8 100644 --- a/src/components/elements/ResultsTable.tsx +++ b/src/components/elements/ResultsTable.tsx @@ -3,6 +3,7 @@ import { Link } from 'react-router-dom' import DifficultyBadge from '@/components/ui/DifficultyBadge' import { getDifficultyLetter } from '@/lib/difficulty' import { groupLabel, type ElementListRow } from '@/lib/elementList' +import { imageUrlForApp } from '@/lib/imageUrl' // On a wide screen the results are a table. On a phone and a small screen (below // the "md" breakpoint) they are cards below each other instead, because four @@ -82,7 +83,7 @@ function ResultCards({ rows }: { rows: ElementListRow[] }) { {/* The drawing is below the text. If it is missing, nothing is shown, and not a broken image. */} {`Illustration void, + onFailed: (message: string) => void, +) { + // The gymnasts can have changed on the other page, so the cache is shown and the server is asked. + const gymnasts = useQuery(GYMNASTS_QUERY, { + fetchPolicy: 'cache-and-network', + }) + const [addElement, { loading: adding }] = useMutation( + ADD_GYMNAST_ELEMENT_MUTATION, + ) + + async function add(gymnast: { id: string; name: string }) { + if (adding) return + try { + await addElement({ + variables: { gymnastId: gymnast.id, itemId: element.id }, + }) + } catch (problem) { + onFailed(describeError(problem).message) + return + } + onAdded(`${element.code} added to ${gymnast.name}.`) + } + + return { + /** The gymnasts of the coach, or undefined until the first answer. */ + list: gymnasts.data?.me?.gymnasts, + loading: gymnasts.loading, + error: gymnasts.error, + adding, + add, + } +} diff --git a/src/components/goToTop/GoToTop.tsx b/src/components/goToTop/GoToTop.tsx new file mode 100644 index 0000000..87664dc --- /dev/null +++ b/src/components/goToTop/GoToTop.tsx @@ -0,0 +1,24 @@ +/** + * A link at the end of every page that takes a keyboard or screen reader user back to the + * start of the page, so they do not have to go back through everything. It is read by a + * screen reader and not seen, until it has the focus (like the skip link), and then it shows. + * + * It scrolls to the top and puts the focus on the first thing that can be reached, which is + * the "Skip to main content" link. A plain `#` link would only scroll, and the focus would + * stay at the bottom. It does not follow the link, so the address and the history do not change. + */ +export const GoToTop = () => { + return ( + { + event.preventDefault() + window.scrollTo({ top: 0 }) + document.querySelector('a[href], button')?.focus() + }} + className="sr-only rounded-sm font-text font-semibold text-(--text-color) underline underline-offset-4 focus:not-sr-only focus:block focus:w-fit focus:my-4 focus:ml-5 focus:px-md focus:py-sm sm:focus:ml-8 lg:focus:ml-12 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--text-color)" + > + Go to the top of the page + + ) +} diff --git a/src/components/gymnasts/AddButton.tsx b/src/components/gymnasts/AddButton.tsx new file mode 100644 index 0000000..8eb58ce --- /dev/null +++ b/src/components/gymnasts/AddButton.tsx @@ -0,0 +1,27 @@ +import type { ReactNode } from 'react' +import { Plus, type LucideIcon } from 'lucide-react' +import LinkButton from '@/components/ui/LinkButton' + +/** + * The pill button that is a link, with an icon before the text: a plus to add something + * ("New routine", "Add from search"), or another icon for another meaning (a lock on "Add to + * gymnast" when the visitor must log in first). `state` is the state of the navigation, for + * the login page, which sends the visitor back to the page in `state.from`. + */ +export default function AddButton({ + to, + state, + icon = Plus, + children, +}: { + to: string + state?: unknown + icon?: LucideIcon + children: ReactNode +}) { + return ( + + {children} + + ) +} diff --git a/src/components/gymnasts/ApparatusTabs.tsx b/src/components/gymnasts/ApparatusTabs.tsx new file mode 100644 index 0000000..589d90c --- /dev/null +++ b/src/components/gymnasts/ApparatusTabs.tsx @@ -0,0 +1,60 @@ +import type { ReactNode } from 'react' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import type { ApparatusView } from '@/lib/gymnastDetail' + +/** + * One tab for each apparatus, with only its name. It is the shadcn/ui + * tabs (`components/ui/tabs.tsx`, on the Base UI tabs, so the WAI-ARIA tabs pattern: arrow + * keys, Home and End, a panel for each tab) in its "line" look. The panel of the chosen + * apparatus is made by `renderPanel`. The tabs follow the focus, because a panel shows at + * once and costs nothing to open. + * + * The classes only set what the shadcn/ui defaults cannot do on this site: the colors of the + * site (the defaults are 60 % gray text and a faint focus ring, which are not enough, WCAG + * 1.4.3 and 2.4.7), a tab that is 44 px high, and a hover that can be seen. The chosen tab + * has the line of the component under it and a bold name, so it is not told by a tone alone + * (WCAG 1.4.1), and `aria-selected` tells a screen reader. + */ +export default function ApparatusTabs({ + apparatus, + value, + onValueChange, + renderPanel, +}: { + apparatus: ApparatusView[] + value: string + onValueChange: (id: string) => void + renderPanel: (apparatus: ApparatusView) => ReactNode +}) { + return ( + onValueChange(String(id))}> + + {apparatus.map((one) => ( + + + {one.name} + + + ))} + + {apparatus.map((one) => ( + + {renderPanel(one)} + + ))} + + ) +} diff --git a/src/components/gymnasts/DeleteGymnastButton.tsx b/src/components/gymnasts/DeleteGymnastButton.tsx index 6108f67..7a3a9a9 100644 --- a/src/components/gymnasts/DeleteGymnastButton.tsx +++ b/src/components/gymnasts/DeleteGymnastButton.tsx @@ -1,8 +1,7 @@ -import { useState } from 'react' +import { useRef, useState } from 'react' import { useMutation } from '@apollo/client/react' -import { AlertDialog } from '@base-ui/react/alert-dialog' -import { Trash2, TriangleAlert } from 'lucide-react' -import { Button } from '@/components/ui/button' +import ConfirmDialog from '@/components/ConfirmDialog' +import TrashButton from '@/components/gymnasts/TrashButton' import { describeError } from '@/lib/apollo/errors' import { DELETE_GYMNAST_MUTATION, @@ -13,10 +12,11 @@ import type { GymnastSummary } from '@/lib/gymnasts' /** * Deletes a gymnast, after the coach has confirmed it in a dialog. Deleting * cannot be undone and takes the gymnast's routines and list of elements with - * it, so a click on the icon only opens the question. The dialog is the Base UI - * alert dialog: the focus stays inside it, Escape closes it, and it is read as - * a dialog with a title and a description. The button is named after the - * gymnast, so a list of them can be told apart by a screen reader. + * it, so a click on the icon only opens the question. The question is the shared + * `ConfirmDialog` (the Base UI alert dialog: the focus stays inside it, Escape + * closes it, and it is read as a dialog with a title and a description). The + * button is named after the gymnast, so a list of them can be told apart by a + * screen reader. * * `onDeleted` is called with the name when the server has deleted the gymnast, * and `onFailed` with the message when it has not. The page tells the visitor @@ -32,62 +32,45 @@ export default function DeleteGymnastButton({ onFailed: (message: string) => void }) { const [open, setOpen] = useState(false) + // True after a deletion: the page has moved the focus, and the dialog must not move it back + // to this button, which is about to be gone. + const deleted = useRef(false) // The list is fetched again: the gymnast is gone from it, and the API sorts it. - const [deleteGymnast, { loading }] = useMutation(DELETE_GYMNAST_MUTATION, { + const [deleteGymnast] = useMutation(DELETE_GYMNAST_MUTATION, { refetchQueries: [GYMNASTS_QUERY], }) async function confirm() { + deleted.current = false try { await deleteGymnast({ variables: { id: gymnast.id } }) - setOpen(false) - onDeleted(gymnast.name) } catch (problem) { setOpen(false) onFailed(describeError(problem).message) + return } + deleted.current = true + setOpen(false) + onDeleted(gymnast.name) } return ( - - - } - > - - - {/* The page behind is dimmed. */} - - {/* A card like the others on the site (rounded-lg), with the warning, the - question and the two answers. The first thing in it that can be reached - is "No, keep it", so Enter right away does not delete. */} - - - - + + } + confirmLabel="Yes, delete" + busyLabel="Deleting…" + onConfirm={confirm} + finalFocus={() => !deleted.current} + /> + ) } diff --git a/src/components/gymnasts/DevLoginButton.test.tsx b/src/components/gymnasts/DevLoginButton.test.tsx deleted file mode 100644 index 8a45cb3..0000000 --- a/src/components/gymnasts/DevLoginButton.test.tsx +++ /dev/null @@ -1,134 +0,0 @@ -// @vitest-environment jsdom - -import { cleanup, fireEvent, render, screen } from "@testing-library/react"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { ApiError } from "@/lib/apollo/api"; -import { devLogin } from "@/lib/apollo/client"; -import { isDevLoginEnabled } from "@/lib/devLogin"; -import DevLoginButton from "./DevLoginButton"; - -vi.mock("@/lib/apollo/client", () => ({ devLogin: vi.fn() })); - -const logIn = vi.mocked(devLogin); -const BUTTON = "Log in as test coach"; -const EMAIL = "emma@example.com"; -const coach = { id: "c1", name: "emma", email: EMAIL }; - -afterEach(() => { - cleanup(); - vi.resetAllMocks(); - vi.unstubAllEnvs(); -}); - -function typeEmail(address: string) { - fireEvent.change(screen.getByLabelText("Your e-mail address"), { - target: { value: address }, - }); -} - -describe("isDevLoginEnabled", () => { - it("is on only when the build was made with VITE_DEV_LOGIN=true", () => { - for (const [value, expected] of [ - ["true", true], - ["", false], - ["false", false], - ["1", false], - ] as const) { - vi.stubEnv("VITE_DEV_LOGIN", value); - expect(isDevLoginEnabled()).toBe(expected); - } - }); -}); - -describe("DevLoginButton", () => { - it("warns that anyone can log in as anyone", () => { - render( Promise.resolve()} />); - - expect( - screen.getByText( - "Test login: anyone can log in as anyone, so don’t enter real data.", - ), - ).toBeTruthy(); - }); - - it("logs in as the address that was typed, then loads what the page shows", async () => { - logIn.mockResolvedValue(coach); - const onLoggedIn = vi.fn().mockResolvedValue(undefined); - render(); - - typeEmail(EMAIL); - fireEvent.click(screen.getByRole("button", { name: BUTTON })); - - await vi.waitFor(() => expect(onLoggedIn).toHaveBeenCalledTimes(1)); - expect(logIn).toHaveBeenCalledExactlyOnceWith(EMAIL); - expect(screen.queryByRole("alert")).toBeNull(); - }); - - it("starts with no address, so nobody logs in as someone else's coach by default", () => { - render( Promise.resolve()} />); - - expect( - screen.getByLabelText("Your e-mail address").value, - ).toBe(""); - }); - - it("is disabled while it logs in, so a double click makes one login", async () => { - let finish!: () => void; - logIn.mockReturnValue( - new Promise((done) => { - finish = () => done(coach); - }), - ); - render( Promise.resolve()} />); - - typeEmail(EMAIL); - fireEvent.click(screen.getByRole("button", { name: BUTTON })); - const busy = await screen.findByRole("button", { - name: "Logging in…", - }); - expect(busy.disabled).toBe(true); - fireEvent.click(busy); - finish(); - - await screen.findByRole("button", { name: BUTTON }); - expect(logIn).toHaveBeenCalledTimes(1); - }); - - it("shows the server's answer when it refuses the address, and keeps what was typed", async () => { - logIn.mockRejectedValue( - new ApiError("BAD_USER_INPUT", "Enter a valid e-mail address."), - ); - const onLoggedIn = vi.fn(); - render(); - - typeEmail("emma@x"); - fireEvent.click(screen.getByRole("button", { name: BUTTON })); - - expect((await screen.findByRole("alert")).textContent).toBe( - "Enter a valid e-mail address.", - ); - expect(onLoggedIn).not.toHaveBeenCalled(); - expect( - screen.getByLabelText("Your e-mail address").value, - ).toBe("emma@x"); - }); - - it("says so when the server has the development login turned off", async () => { - logIn.mockRejectedValue( - new ApiError("NOT_IMPLEMENTED", "devLogin is not available yet."), - ); - const onLoggedIn = vi.fn(); - render(); - - typeEmail(EMAIL); - fireEvent.click(screen.getByRole("button", { name: BUTTON })); - - expect((await screen.findByRole("alert")).textContent).toBe( - "This is not available yet.", - ); - expect(onLoggedIn).not.toHaveBeenCalled(); - expect( - screen.getByRole("button", { name: BUTTON }).disabled, - ).toBe(false); - }); -}); diff --git a/src/components/gymnasts/DevLoginButton.tsx b/src/components/gymnasts/DevLoginButton.tsx index 9efb942..50ab423 100644 --- a/src/components/gymnasts/DevLoginButton.tsx +++ b/src/components/gymnasts/DevLoginButton.tsx @@ -1,8 +1,8 @@ -import { useState, type FormEvent } from "react"; -import { Button } from "@/components/ui/button"; -import { PILL } from "@/components/ui/pill"; -import { devLogin } from "@/lib/apollo/client"; -import { describeError } from "@/lib/apollo/errors"; +import { useState, type FormEvent } from 'react' +import { Button } from '@/components/ui/button' +import { PILL } from '@/components/ui/pill' +import { devLogin } from '@/lib/apollo/client' +import { describeError } from '@/lib/apollo/errors' /** * Logs in as the coach with the e-mail address that the person types, without @@ -14,24 +14,24 @@ import { describeError } from "@/lib/apollo/errors"; export default function DevLoginButton({ onLoggedIn, }: { - onLoggedIn: () => Promise; + onLoggedIn: () => Promise }) { - const [email, setEmail] = useState(""); - const [isLoggingIn, setIsLoggingIn] = useState(false); - const [error, setError] = useState(""); + const [email, setEmail] = useState('') + const [isLoggingIn, setIsLoggingIn] = useState(false) + const [error, setError] = useState('') async function logIn(event: FormEvent) { - event.preventDefault(); - if (isLoggingIn) return; - setIsLoggingIn(true); - setError(""); + event.preventDefault() + if (isLoggingIn) return + setIsLoggingIn(true) + setError('') try { - await devLogin(email); - await onLoggedIn(); + await devLogin(email) + await onLoggedIn() } catch (problem) { - setError(describeError(problem).message); + setError(describeError(problem).message) } finally { - setIsLoggingIn(false); + setIsLoggingIn(false) } } @@ -58,11 +58,11 @@ export default function DevLoginButton({ required value={email} onChange={(event) => { - setEmail(event.target.value); - setError(""); + setEmail(event.target.value) + setError('') }} aria-invalid={Boolean(error)} - aria-describedby={error ? "dev-login-error" : undefined} + aria-describedby={error ? 'dev-login-error' : undefined} className="h-11 w-full rounded-md border border-(--field-border) bg-(--field-bg) px-3 text-(--text-color) focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--text-color)" /> {error && (

)} - ); + ) } diff --git a/src/components/gymnasts/GymnastElementList.tsx b/src/components/gymnasts/GymnastElementList.tsx new file mode 100644 index 0000000..0d2039f --- /dev/null +++ b/src/components/gymnasts/GymnastElementList.tsx @@ -0,0 +1,102 @@ +import AddButton from '@/components/gymnasts/AddButton' +import ListSection from '@/components/gymnasts/ListSection' +import { ROW_HOVER } from '@/components/gymnasts/listCard' +import StretchedLink from '@/components/gymnasts/StretchedLink' +import { TriangleAlert } from 'lucide-react' +import TrashButton from '@/components/gymnasts/TrashButton' +import DifficultyBadge from '@/components/ui/DifficultyBadge' +import { getDifficultyLetter } from '@/lib/difficulty' +import { + addFromSearchHref, + elementLabel, + elementsHeadingId, + type ElementView, +} from '@/lib/gymnastDetail' + +/** One element of the list: its code and name (a link to the element), its difficulty, and a button that asks to remove it. */ +function ElementRow({ + element, + onAskRemove, +}: { + element: ElementView + onAskRemove: (element: ElementView, opener: HTMLElement) => void +}) { + const label = elementLabel(element) + // An element has a value 0.1-1.0 that is shown as a letter A-J; a vault has a D-value. + const difficulty = element.isVault + ? element.value + : (getDifficultyLetter(element.value) ?? element.value) + + return ( +

  • +
    + + {/* The code is above the name on a narrow screen: beside it, the name got about 60 px + at 320 px wide, and one word a line. */} + {element.code}{' '} + {label} + + {element.removed && ( +

    +

    + )} +
    + + {/* Above the link that covers the row (its ::after), so the button can be pressed. */} + onAskRemove(element, event.currentTarget)} + className="relative z-10" + /> +
  • + ) +} + +/** + * The elements the gymnast can do or is practicing on one apparatus, in the order they were + * added, and the "Add from search" button, which goes to the search with the gymnast in the + * address (`addFromSearchHref`). The page can move the focus to the heading + * (`elementsHeadingId`): after an element has been removed the button that was pressed is + * gone, and this is where the visitor was. The id has the apparatus in it, because the panel + * of the apparatus that was left is still in the page for a moment, and two headings must not + * have the same id. + */ +export default function GymnastElementList({ + apparatusId, + elements, + apparatusName, + gymnastId, + onAskRemove, +}: { + apparatusId: string + elements: ElementView[] + apparatusName: string + gymnastId: string + /** The visitor pressed the remove button of an element. The page asks "are you sure?". */ + onAskRemove: (element: ElementView, opener: HTMLElement) => void +}) { + return ( + ( + + )} + action={ + Add from search + } + /> + ) +} diff --git a/src/components/gymnasts/GymnastHeader.tsx b/src/components/gymnasts/GymnastHeader.tsx new file mode 100644 index 0000000..386ba5e --- /dev/null +++ b/src/components/gymnasts/GymnastHeader.tsx @@ -0,0 +1,18 @@ +import { ChevronLeft } from 'lucide-react' +import { Link } from 'react-router-dom' + +/** The way back and the name of the gymnast (the h1 of the page). */ +export default function GymnastHeader({ name }: { name: string }) { + return ( +
    + +
    + ) +} diff --git a/src/components/gymnasts/GymnastList.test.tsx b/src/components/gymnasts/GymnastList.test.tsx deleted file mode 100644 index 6d59d1d..0000000 --- a/src/components/gymnasts/GymnastList.test.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { ApolloProvider } from "@apollo/client/react"; -import { renderToString } from "react-dom/server"; -import { describe, expect, it } from "vitest"; -import { createTestClient } from "@/tests/apollo"; -import GymnastList from "./GymnastList"; - -// Each row has a delete button, which uses the client for its mutation. -const { client } = createTestClient(() => new Promise(() => {})); -const renderList = (gymnasts: { id: string; name: string }[]) => - renderToString( - - {}} onFailed={() => {}} /> - , - ); - -describe("GymnastList", () => { - it("renders names with initials", () => { - const markup = renderList([ - { id: "one", name: "Ingrid Haugen" }, - { id: "two", name: "Emma Berg" }, - ]); - - expect(markup).toContain("Ingrid Haugen"); - expect(markup).toContain("IH"); - expect(markup).toContain("Emma Berg"); - expect(markup).toContain("EB"); - }); - - it("has a heading for the list", () => { - const markup = renderList([]); - - expect(markup).toMatch(/]*>Gymnast list<\/h2>/); - }); - - it("announces the empty list state", () => { - const markup = renderList([]); - - expect(markup).toContain('role="status"'); - expect(markup).toContain("You haven’t added any gymnasts yet."); - }); -}); - -describe("GymnastList: the cards show that they can be pressed", () => { - it("has a tint and a deeper shadow on hover, a pointer, and no movement for a visitor who asks for less", () => { - const markup = renderList([{ id: "one", name: "Ingrid Haugen" }]); - - expect(markup).toContain("cursor-pointer"); - expect(markup).toContain("hover:bg-[color-mix(in_srgb,var(--surface),var(--text-color)_8%)]"); - expect(markup).toContain("hover:shadow-[0_6px_16px_rgba(3,2,2,0.22)]"); - expect(markup).toContain("motion-reduce:transition-none"); - }); -}); diff --git a/src/components/gymnasts/GymnastList.tsx b/src/components/gymnasts/GymnastList.tsx index 20c5f8f..ba8bcbd 100644 --- a/src/components/gymnasts/GymnastList.tsx +++ b/src/components/gymnasts/GymnastList.tsx @@ -1,20 +1,17 @@ -import GymnastRow from "@/components/gymnasts/GymnastRow"; -import type { GymnastSummary } from "@/lib/gymnasts"; +import GymnastRow from '@/components/gymnasts/GymnastRow' +import type { GymnastSummary } from '@/lib/gymnasts' export default function GymnastList({ gymnasts, onDeleted, onFailed, }: { - gymnasts: GymnastSummary[]; - onDeleted: (name: string) => void; - onFailed: (message: string) => void; + gymnasts: GymnastSummary[] + onDeleted: (name: string) => void + onFailed: (message: string) => void }) { return ( -
    +

    Gymnast list

    @@ -23,7 +20,7 @@ export default function GymnastList({ You haven’t added any gymnasts yet.

    ) : ( -
      +
        {gymnasts.map((gymnast) => ( )}
    - ); -} \ No newline at end of file + ) +} diff --git a/src/components/gymnasts/GymnastPageStates.tsx b/src/components/gymnasts/GymnastPageStates.tsx new file mode 100644 index 0000000..5956452 --- /dev/null +++ b/src/components/gymnasts/GymnastPageStates.tsx @@ -0,0 +1,72 @@ +import ErrorPage from '@/components/ErrorPage' +import PageTitle from '@/components/PageTitle' +import LinkButton from '@/components/ui/LinkButton' +import type { DescribedError } from '@/lib/apollo/errors' +import { LIST_CARD } from '@/components/gymnasts/listCard' + +/** The classes of the `
    ` of the Gymnast page, in all its states. */ +export const GYMNAST_PAGE = + 'gymnast-page min-h-svh bg-(--page-bg) text-(--text-color) px-5 py-7 text-left sm:px-8 sm:py-8 lg:px-12' + +/** The server says that this gymnast is not one of the coach's (or does not exist). */ +export function GymnastNotFound() { + return ( + Back to your gymnasts} + /> + ) +} + +/** The gymnast could not be loaded: what went wrong, and a way on (log in, or try again). */ +export function GymnastUnavailable({ + problem, + gymnastId, + onRetry, +}: { + problem: DescribedError + gymnastId: string + onRetry: () => void +}) { + return ( + + Log in + + ) + } + /> + ) +} + +/** A skeleton of the page while the first answer is on its way. */ +export function GymnastLoading() { + return ( +
    + Loading gymnast +

    + Loading gymnast +

    +
    + ) +} diff --git a/src/components/gymnasts/GymnastRow.tsx b/src/components/gymnasts/GymnastRow.tsx index 8b8b26f..44d0f4a 100644 --- a/src/components/gymnasts/GymnastRow.tsx +++ b/src/components/gymnasts/GymnastRow.tsx @@ -1,4 +1,6 @@ import DeleteGymnastButton from '@/components/gymnasts/DeleteGymnastButton' +import { CARD_LIFT, LIST_CARD, ROW_HOVER } from '@/components/gymnasts/listCard' +import StretchedLink from '@/components/gymnasts/StretchedLink' import type { GymnastSummary } from '@/lib/gymnasts' import { getGymnastInitials } from '@/lib/gymnasts' @@ -12,7 +14,9 @@ export default function GymnastRow({ onFailed: (message: string) => void }) { return ( -
  • +
  • -

    {gymnast.name}

    - +

    + + {gymnast.name} + +

    +
    + +
  • ) diff --git a/src/components/gymnasts/ListSection.tsx b/src/components/gymnasts/ListSection.tsx new file mode 100644 index 0000000..9c77808 --- /dev/null +++ b/src/components/gymnasts/ListSection.tsx @@ -0,0 +1,56 @@ +import { Fragment, type ReactNode } from 'react' +import { LIST_CARD } from '@/components/gymnasts/listCard' + +/** + * A list of the Gymnast page (the routines, the elements): a section with a heading, an + * optional action beside the heading (a button), a text when there is nothing, and otherwise + * the rows in one card. What a row looks like is the caller's (`renderRow`), because a routine + * and an element have nothing in common but the `
  • `; what is common is here, so the + * structure that a screen reader reads (a named section with a named list) is made in one + * place. + * + * The heading can get the focus from the code (`tabIndex={-1}`, so it is not in the tab order), + * which the page uses after an element has been removed and the button that was pressed is gone. + */ +export default function ListSection({ + headingId, + title, + items, + emptyText, + renderRow, + action, +}: { + /** The id of the heading. It names the section and the list, and it can be looked up to move the focus. */ + headingId: string + title: string + items: T[] + /** What is said when `items` is empty. */ + emptyText: string + /** One `
  • ` for one item. The key is set here. */ + renderRow: (item: T) => ReactNode + /** Shown to the right of the heading. */ + action?: ReactNode +}) { + return ( +
    +
    +

    + {title} +

    + {action} +
    + {items.length === 0 ? ( +

    {emptyText}

    + ) : ( +
      + {items.map((item) => ( + {renderRow(item)} + ))} +
    + )} +
    + ) +} diff --git a/src/components/gymnasts/RoutineList.tsx b/src/components/gymnasts/RoutineList.tsx new file mode 100644 index 0000000..5796dd3 --- /dev/null +++ b/src/components/gymnasts/RoutineList.tsx @@ -0,0 +1,90 @@ +import AddButton from '@/components/gymnasts/AddButton' +import ListSection from '@/components/gymnasts/ListSection' +import { ROW_HOVER } from '@/components/gymnasts/listCard' +import { TriangleAlert } from 'lucide-react' +import { + countText, + formatUpdated, + newRoutineHref, + type RoutineView, +} from '@/lib/gymnastDetail' + +/** + * One routine: its name, how big it is, when it changed and its D-score. The "Highest" label has + * the color of the buttons of the site (`--button-bg`, `--button-text`). In light mode that is the + * color of the card too, so the label has an edge (`--box-stroke`) to be seen against it. + */ +function RoutineRow({ routine }: { routine: RoutineView }) { + return ( +
  • +
    +

    + + {routine.name} + + {routine.isHighest && ( + + Highest + + )} +

    +

    + {countText(routine.itemCount, 'element')} + Updated{' '} + +

    + {routine.hasRemovedItem && ( +

    +

    + )} +
    +

    + {routine.score === null ? ( + + ) : ( + <> + D-score + {routine.score.toFixed(1)} + + )} +

    +
  • + ) +} + +/** + * The routines of one apparatus, highest score first, and the "New routine" button, which + * goes to the calculator with the gymnast in the address (`newRoutineHref`). + */ +export default function RoutineList({ + routines, + apparatusName, + gymnastId, +}: { + routines: RoutineView[] + apparatusName: string + gymnastId: string +}) { + return ( + } + action={New routine} + /> + ) +} diff --git a/src/components/gymnasts/StretchedLink.tsx b/src/components/gymnasts/StretchedLink.tsx new file mode 100644 index 0000000..9eb3ba4 --- /dev/null +++ b/src/components/gymnasts/StretchedLink.tsx @@ -0,0 +1,19 @@ +import { Link, type LinkProps } from 'react-router-dom' + +/** + * A link that covers the whole card or row it is in (its `::after` is stretched over the + * nearest positioned parent), so the card can be pressed anywhere, as the pointer and the + * hover suggest. The parent must be `relative`, and a button inside the card must be + * `relative z-10` to stay above the link. The focus ring is on the link itself. + */ +export default function StretchedLink({ + className = '', + ...props +}: Omit & { className?: string }) { + return ( + + ) +} diff --git a/src/components/gymnasts/TrashButton.tsx b/src/components/gymnasts/TrashButton.tsx new file mode 100644 index 0000000..d0e1cf7 --- /dev/null +++ b/src/components/gymnasts/TrashButton.tsx @@ -0,0 +1,41 @@ +import type { MouseEvent } from 'react' +import { Trash2 } from 'lucide-react' +import { Button } from '@/components/ui/button' + +/** + * The trash icon button of the gymnasts pages: the one that deletes a gymnast on "Your + * gymnasts" and the one that removes an element on the Gymnast page. Both look and work the + * same, so they are this one component. It only asks (`onClick`); the dialog is the caller's. + * + * The icon is in the text color until the pointer is over it or it is pressed, and then it + * turns red with a tint of the red behind it. Red is the strongest color of the site, so it + * is kept for the moment it matters and a list of many rows is not a list of many red icons. + * `hover:` only applies to a device that can hover (Tailwind 4 wraps it in + * `@media (hover: hover)`), so a phone gets the red from `active:`, while the finger is down. The button is + * 48 by 48 CSS pixels (WCAG 2.5.8 asks for 24, 2.5.5 for 44), and the icon needs 3:1 on the + * card in both themes (checked in gymnastTheme.test.ts). `label` is what a screen reader + * says, so a list of them can be told apart. "Solid": the shared button sets `outline-none`, + * which a width and a color alone do not undo. + */ +export default function TrashButton({ + label, + onClick, + className = '', +}: { + label: string + onClick: (event: MouseEvent) => void + className?: string +}) { + return ( + + ) +} diff --git a/src/components/gymnasts/listCard.ts b/src/components/gymnasts/listCard.ts new file mode 100644 index 0000000..7d6227b --- /dev/null +++ b/src/components/gymnasts/listCard.ts @@ -0,0 +1,15 @@ +/** + * The card behind a list of the Gymnast page (the routines and the elements): the color of + * the cards on the Elements page and the gymnasts page (`--surface`) and their shadow. + */ +export const LIST_CARD = 'bg-(--surface) shadow-(--box-shadow)' +/** + * The hover of a row or a card: the color is the theme value `--card-hover` (a tint of the text + * color in light mode, a deeper navy in dark mode, see variables.css), which keeps the text above + * 4.5:1 in both themes (gymnastTheme.test.ts). The pointer is for the rows that can be pressed. + * The shadow is in the transition too, for a card that also gets `CARD_LIFT`. + */ +export const ROW_HOVER = + 'transition-[background-color,box-shadow] duration-150 hover:bg-(--card-hover) motion-reduce:transition-none' +/** A card that stands alone (a gymnast on "Your gymnasts") also gets a deeper shadow on hover. */ +export const CARD_LIFT = 'hover:shadow-[0_6px_16px_rgba(3,2,2,0.22)]' diff --git a/src/components/gymnasts/useElementRemoval.ts b/src/components/gymnasts/useElementRemoval.ts new file mode 100644 index 0000000..c338f3e --- /dev/null +++ b/src/components/gymnasts/useElementRemoval.ts @@ -0,0 +1,76 @@ +import { useEffect, useRef, useState } from 'react' +import { useMutation } from '@apollo/client/react' +import { describeError } from '@/lib/apollo/errors' +import { elementsHeadingId, type ElementView } from '@/lib/gymnastDetail' +import { REMOVE_GYMNAST_ELEMENT_MUTATION } from '@/lib/gymnastDetailOperations' + +/** + * Removing an element from the list of a gymnast: the question "are you sure?" (the page + * shows it with `ConfirmDialog`), the removal, what is told to the visitor, and where the + * focus goes. The answer of the server updates the Apollo cache, so the list on the page is + * the new list without a second request. + */ +export function useElementRemoval(gymnastId: string) { + const [removeElement] = useMutation(REMOVE_GYMNAST_ELEMENT_MUTATION) + // The element the visitor is asked about, and whether the question is open. The element is + // kept after the question closes, so the text of it does not change while it closes. + const [pending, setPending] = useState(null) + const [asking, setAsking] = useState(false) + // Said to a screen reader (a status region) when an element has been removed. + const [announcement, setAnnouncement] = useState('') + const [error, setError] = useState('') + // True when the last removal worked: the button that was pressed is gone with its row, so + // the focus goes to the heading of the list, which is where the visitor was. + const removedLast = useRef(false) + // The button that opened the question, which gets the focus back when the answer is no. + // Not every browser focuses a button when it is clicked (Safari does not), so it is kept. + const opener = useRef(null) + + // After a removal the question has closed and the button is gone. The dialog is told to + // leave the focus alone (`finalFocus`), and it is moved here, when the dialog is gone. + // The heading is found by its id: the page has a list for each apparatus, and the one that + // was left is still there for a moment, so one ref for all of them would end up empty. + useEffect(() => { + if (!asking && removedLast.current && pending) { + removedLast.current = false + document.getElementById(elementsHeadingId(pending.apparatusId))?.focus() + } + }, [asking, pending]) + + function ask(element: ElementView, button: HTMLElement) { + opener.current = button + setPending(element) + setAsking(true) + } + + /** Removes the element that was asked about. On a failure it keeps the list and says why. */ + async function remove(): Promise { + if (!pending) return + setError('') + setAnnouncement('') + removedLast.current = false + try { + await removeElement({ variables: { gymnastId, itemId: pending.id } }) + } catch (problem) { + setError(describeError(problem).message) + return + } + removedLast.current = true + setAnnouncement(`${pending.code} removed from the list.`) + } + + /** Where the dialog gives the focus when it closes: nowhere after a removal (see above), else back. */ + const finalFocus = () => + removedLast.current ? false : (opener.current ?? true) + + return { + pending, + asking, + setAsking, + announcement, + error, + ask, + remove, + finalFocus, + } +} diff --git a/src/components/navbar/Navbar.tsx b/src/components/navbar/Navbar.tsx index ffe806e..da1a20e 100644 --- a/src/components/navbar/Navbar.tsx +++ b/src/components/navbar/Navbar.tsx @@ -1,41 +1,41 @@ -import React, { useState } from 'react'; -import { Menu, X } from 'lucide-react'; -import { Link, NavLink, useNavigate } from 'react-router-dom'; -import { useAuth } from '@/components/auth/useAuth'; -import { logout } from '@/lib/apollo/client'; +import React, { useState } from 'react' +import { Menu, X } from 'lucide-react' +import { Link, NavLink, useNavigate } from 'react-router-dom' +import { useAuth } from '@/components/auth/useAuth' +import { logout } from '@/lib/apollo/client' interface NavbarProps { - isDarkMode: boolean; - onToggleDarkMode: () => void; + isDarkMode: boolean + onToggleDarkMode: () => void } export const Navbar: React.FC = ({ isDarkMode, onToggleDarkMode, }) => { - const [menuOpen, setMenuOpen] = useState(false); + const [menuOpen, setMenuOpen] = useState(false) // The same answer as the pages read: who is logged in, as the server says it. - const { status } = useAuth(); - const isLoggedIn = status === 'authenticated'; - const navigate = useNavigate(); + const { status } = useAuth() + const isLoggedIn = status === 'authenticated' + const navigate = useNavigate() async function logOut() { - setMenuOpen(false); + setMenuOpen(false) // Leave a page that is for coaches first, so the visitor is not sent to the // login page by the page they are leaving. - void navigate('/elements'); - await logout(); + void navigate('/elements') + await logout() } const focusRing = - 'focus-visible:outline-2 focus-visible:outline-(--text-color) focus-visible:outline-offset-4'; + 'focus-visible:outline-2 focus-visible:outline-(--text-color) focus-visible:outline-offset-4' const authButton = - 'inline-flex min-h-6 min-w-6 shrink items-center whitespace-nowrap rounded-corner border border-border-color bg-transparent px-[clamp(11px,12px,13px)] py-xs font-text text-[clamp(var(--font-size-row-title),1.2vw,1rem)] text-text-color'; + 'inline-flex min-h-6 min-w-6 shrink items-center whitespace-nowrap rounded-corner border border-border-color bg-transparent px-[clamp(11px,12px,13px)] py-xs font-text text-[clamp(var(--font-size-row-title),1.2vw,1rem)] text-text-color' const navLinks = [ { path: '/elements', label: 'Search' }, { path: '/calculator', label: 'Calculator' }, { path: '/about', label: 'About' }, ...(isLoggedIn ? [{ path: '/gymnasts', label: 'Gymnasts' }] : []), - ]; + ] return (
    @@ -84,9 +84,7 @@ export const Navbar: React.FC = ({ aria-hidden="true" className="flex h-[20px] w-[20px] items-center justify-center rounded-full bg-primary-color text-xs transition-transform duration-200 ease-in-out" style={{ - transform: isDarkMode - ? 'translateX(22px)' - : 'translateX(0)', + transform: isDarkMode ? 'translateX(22px)' : 'translateX(0)', }} > {isDarkMode ? '🌙' : '☀️'} @@ -104,7 +102,10 @@ export const Navbar: React.FC = ({ Log out ) : ( - + Log in ))} @@ -125,5 +126,5 @@ export const Navbar: React.FC = ({
    - ); -}; \ No newline at end of file + ) +} diff --git a/src/components/skipLink/SkipLink.test.tsx b/src/components/skipLink/SkipLink.test.tsx deleted file mode 100644 index 6fa0275..0000000 --- a/src/components/skipLink/SkipLink.test.tsx +++ /dev/null @@ -1,33 +0,0 @@ -// @vitest-environment jsdom -import { render, screen } from '@testing-library/react'; -import { describe, expect, it } from 'vitest'; -import { SkipLink } from './SkipLink'; - -describe('SkipLink', () => { - it('links to the main content using the default id', () => { - render(); - - expect(screen.getByRole('link', { name: 'Skip to main content' }).getAttribute('href')).toBe( - '#main-content', - ); - }); - - it('supports a custom main content id', () => { - render(); - - expect(screen.getByRole('link', { name: 'Skip to main content' }).getAttribute('href')).toBe( - '#page-content', - ); - }); - - it('uses Tailwind classes to hide the link until keyboard focus', () => { - render(); - - const link = screen.getByRole('link', { name: 'Skip to main content' }); - expect(link.className).toContain('sr-only'); - expect(link.className).toContain('px-md'); - expect(link.className).toContain('focus:not-sr-only'); - expect(link.className).toContain('focus:top-0'); - expect(link.className).toContain('focus:left-0'); - }); -}); diff --git a/src/components/skipLink/SkipLink.tsx b/src/components/skipLink/SkipLink.tsx index 9fb8807..89eebb5 100644 --- a/src/components/skipLink/SkipLink.tsx +++ b/src/components/skipLink/SkipLink.tsx @@ -1,5 +1,5 @@ interface SkipLinkProps { - mainId?: string; + mainId?: string } export const SkipLink = ({ mainId = 'main-content' }: SkipLinkProps) => { @@ -10,5 +10,5 @@ export const SkipLink = ({ mainId = 'main-content' }: SkipLinkProps) => { > Skip to main content - ); -}; \ No newline at end of file + ) +} diff --git a/src/components/ui/DifficultyBadge.tsx b/src/components/ui/DifficultyBadge.tsx index cda52be..2b80caf 100644 --- a/src/components/ui/DifficultyBadge.tsx +++ b/src/components/ui/DifficultyBadge.tsx @@ -24,7 +24,7 @@ export default function DifficultyBadge({ className={cn( 'inline-flex size-10 items-center justify-center rounded-full', isNumber ? 'text-base' : 'text-lg', - 'bg-(--text-color-dark) px-1 font-semibold leading-none text-(--text-color-light)', + 'bg-(--pill-bg) px-1 font-semibold leading-none text-(--pill-text)', )} > Difficulty @@ -32,7 +32,7 @@ export default function DifficultyBadge({ {value !== undefined && ,} {value !== undefined && ( - + Value {value.toFixed(1)} diff --git a/src/components/ui/ElementValueBadge.tsx b/src/components/ui/ElementValueBadge.tsx deleted file mode 100644 index 049834e..0000000 --- a/src/components/ui/ElementValueBadge.tsx +++ /dev/null @@ -1,16 +0,0 @@ -export default function ElementValueBadge({ - label, - value, -}: { - label: string - value: number -}) { - const formattedValue = value.toFixed(1) - - return ( - - {label} - {formattedValue} - - ) -} diff --git a/src/components/ui/ElementVariants.tsx b/src/components/ui/ElementVariants.tsx index 00b069c..b91916a 100644 --- a/src/components/ui/ElementVariants.tsx +++ b/src/components/ui/ElementVariants.tsx @@ -1,7 +1,6 @@ import { Link } from 'react-router-dom' import { Card, CardContent, CardTitle } from '@/components/ui/card' import DifficultyBadge from '@/components/ui/DifficultyBadge' -import ElementValueBadge from '@/components/ui/ElementValueBadge' import { displayCode, type CatalogVariant } from '@/lib/catalog' import { getDifficultyLetter } from '@/lib/difficulty' @@ -19,23 +18,24 @@ export default function ElementVariants({ Variants of this element ({variants.length}) {variants.length === 0 ? ( -

    +

    This element has no other variants.

    ) : (
      {variants.map((variant) => { + // A letter for an element, the D-value (a number) for a vault. const difficulty = variant.isVault - ? undefined - : getDifficultyLetter(variant.value) + ? variant.value + : (getDifficultyLetter(variant.value) ?? variant.value) return (
    • - +
      @@ -43,18 +43,11 @@ export default function ElementVariants({ {displayCode(variant, true)} {variant.name && ` · ${variant.name}`} -

      +

      {variant.description}

      - {difficulty ? ( - - ) : ( - - )} +
      diff --git a/src/components/ui/LinkButton.tsx b/src/components/ui/LinkButton.tsx new file mode 100644 index 0000000..593dc19 --- /dev/null +++ b/src/components/ui/LinkButton.tsx @@ -0,0 +1,35 @@ +import { cn } from 'cn' +import type { LucideIcon } from 'lucide-react' +import { Link, type LinkProps } from 'react-router-dom' +import { buttonVariants } from '@/components/ui/button' +import { PILL } from '@/components/ui/pill' + +/** + * A link that looks like the pill button of the site, with an optional icon before the text. + * It is a real ``, so a screen reader says "link" for something that goes to another page. + * The Base UI `Button` is not used for this: given a link in `render` it either warns that it is + * not a ` @@ -65,16 +65,16 @@ export default function ElementDetailPage() { return (
      Loading element

      Loading element details

      -
      -
      -
      +
      +
      +
      ) @@ -82,16 +82,16 @@ export default function ElementDetailPage() { if (!item) { return ( -
      +
      Element not found

      Element not found

      -

      +

      No catalog item matches this address.

      View a catalog element @@ -105,7 +105,7 @@ export default function ElementDetailPage() { return (
      {/* The code (with the letter of a variant) and the name, so two tabs of two elements can be told apart. */} {`Element ${displayCode(element, element.variants.length > 0)}${element.name ? ` ${element.name}` : ''} – ${element.apparatusName}`} @@ -114,7 +114,7 @@ export default function ElementDetailPage() { {element.removed && (

      This element is no longer in the Code of Points.

      diff --git a/src/components/elements/ElementsPage.tsx b/src/pages/ElementsPage.tsx similarity index 100% rename from src/components/elements/ElementsPage.tsx rename to src/pages/ElementsPage.tsx diff --git a/src/pages/GymnastPage.tsx b/src/pages/GymnastPage.tsx new file mode 100644 index 0000000..6e03ec9 --- /dev/null +++ b/src/pages/GymnastPage.tsx @@ -0,0 +1,124 @@ +import { useState } from 'react' +import { useQuery } from '@apollo/client/react' +import { useParams } from 'react-router-dom' +import ConfirmDialog from '@/components/ConfirmDialog' +import ErrorMessage from '@/components/ErrorMessage' +import ApparatusTabs from '@/components/gymnasts/ApparatusTabs' +import GymnastElementList from '@/components/gymnasts/GymnastElementList' +import GymnastHeader from '@/components/gymnasts/GymnastHeader' +import { + GYMNAST_PAGE, + GymnastLoading, + GymnastNotFound, + GymnastUnavailable, +} from '@/components/gymnasts/GymnastPageStates' +import RoutineList from '@/components/gymnasts/RoutineList' +import { useElementRemoval } from '@/components/gymnasts/useElementRemoval' +import PageTitle from '@/components/PageTitle' +import { describeError } from '@/lib/apollo/errors' +import { STRUCTURE_QUERY } from '@/lib/elementFilters' +import { buildGymnastView, defaultApparatusId } from '@/lib/gymnastDetail' +import { GYMNAST_DETAIL_QUERY } from '@/lib/gymnastDetailOperations' + +/** One gymnast of the logged-in coach: the routines and the list of elements, one apparatus at a time. */ +export default function GymnastPage() { + const { gymnastId = '' } = useParams() + // The gymnast belongs to the coach and can change in other places, so what is in the + // cache is shown at once and the server is asked in the same moment. + const detail = useQuery(GYMNAST_DETAIL_QUERY, { + variables: { id: gymnastId }, + fetchPolicy: 'cache-and-network', + }) + // The four apparatus in the order of the API: a few rows that are kept for the whole visit. + const structure = useQuery(STRUCTURE_QUERY) + const removal = useElementRemoval(gymnastId) + // The apparatus the visitor chose; until then, the first one that has something. + const [chosen, setChosen] = useState(null) + + const gymnast = detail.data?.gymnast + const apparatus = structure.data?.apparatus + const problem = + detail.error || structure.error + ? describeError(detail.error ?? structure.error) + : null + + if (problem?.code === 'NOT_FOUND' || (detail.data && gymnast === null)) { + return + } + if (problem) { + return ( + + void Promise.all([detail.refetch(), structure.refetch()]).catch( + () => undefined, + ) + } + /> + ) + } + if (!gymnast || !apparatus) return + + const view = buildGymnastView(gymnast, apparatus) + + return ( +
      + {view.name} + {/* React 19 moves a to the head of the document. Only this page has the description. */} + + {/* Says what happened when an element is removed, without moving the focus there. */} +

      + {removal.announcement} +

      + + {removal.error && ( + {removal.error} + )} +
      + ( +
      + {/* One column that can be narrower than its content (minmax(0, 1fr)): without it a + long word makes the column, and the page, wider than a narrow screen. */} + + +
      + )} + /> +
      + + {removal.pending.code} will be removed from {view.name} + + ) + } + confirmLabel="Yes, remove" + busyLabel="Removing…" + onConfirm={removal.remove} + finalFocus={removal.finalFocus} + /> +
      + ) +} diff --git a/src/components/auth/LoginPage.tsx b/src/pages/LoginPage.tsx similarity index 99% rename from src/components/auth/LoginPage.tsx rename to src/pages/LoginPage.tsx index c60e17d..39bc96a 100644 --- a/src/components/auth/LoginPage.tsx +++ b/src/pages/LoginPage.tsx @@ -10,7 +10,7 @@ import { devLogin } from '@/lib/apollo/client' import { describeError } from '@/lib/apollo/errors' import { isDevLoginEnabled } from '@/lib/devLogin' import { isInsecureConnection } from '@/lib/insecureConnection' -import { DEMO_EMAIL } from '../../../shared/demo' +import { DEMO_EMAIL } from '../../shared/demo' /** Where the visitor goes after logging in when they did not come from a protected page. */ const DEFAULT_TARGET = '/gymnasts' diff --git a/src/pages/NotFoundPage.tsx b/src/pages/NotFoundPage.tsx new file mode 100644 index 0000000..a5f8c8d --- /dev/null +++ b/src/pages/NotFoundPage.tsx @@ -0,0 +1,11 @@ +import PageTitle from '@/components/PageTitle' + +// The page for an address that does not exist. It has its own
      , like the real pages. +export default function NotFoundPage() { + return ( +
      + Page not found +

      404 - Page not found

      +
      + ) +} diff --git a/src/components/gymnasts/YourGymnastsPage.tsx b/src/pages/YourGymnastsPage.tsx similarity index 75% rename from src/components/gymnasts/YourGymnastsPage.tsx rename to src/pages/YourGymnastsPage.tsx index c84213c..af5232c 100644 --- a/src/components/gymnasts/YourGymnastsPage.tsx +++ b/src/pages/YourGymnastsPage.tsx @@ -1,102 +1,102 @@ -import { useEffect, useRef, useState } from "react"; -import { useMutation, useQuery } from "@apollo/client/react"; -import { Link } from "react-router-dom"; -import { Check, Plus, X } from "lucide-react"; -import { useAuth } from "@/components/auth/useAuth"; -import PageTitle from "@/components/PageTitle"; -import { Button } from "@/components/ui/button"; -import { PILL } from "@/components/ui/pill"; -import GymnastList from "@/components/gymnasts/GymnastList"; -import { describeError } from "@/lib/apollo/errors"; +import { useEffect, useRef, useState } from 'react' +import { useMutation, useQuery } from '@apollo/client/react' +import { Link } from 'react-router-dom' +import { Check, Plus, X } from 'lucide-react' +import { useAuth } from '@/components/auth/useAuth' +import PageTitle from '@/components/PageTitle' +import { Button } from '@/components/ui/button' +import { PILL } from '@/components/ui/pill' +import GymnastList from '@/components/gymnasts/GymnastList' +import { describeError } from '@/lib/apollo/errors' import { CREATE_GYMNAST_MUTATION, GYMNASTS_QUERY, -} from "@/lib/gymnastOperations"; +} from '@/lib/gymnastOperations' import { GYMNAST_NAME_INPUT_MAX_LENGTH, getGymnastNameError, limitGymnastNameInput, normalizeGymnastName, -} from "@/lib/gymnasts"; +} from '@/lib/gymnasts' export default function YourGymnastsPage() { - const { data, error: loadError, loading, refetch } = useQuery(GYMNASTS_QUERY); + const { data, error: loadError, loading, refetch } = useQuery(GYMNASTS_QUERY) // The list is fetched again after a new gymnast: the API sorts the gymnasts // by name, so the cache cannot know where the new one belongs. const [createGymnast, { loading: isSaving }] = useMutation( CREATE_GYMNAST_MUTATION, { refetchQueries: [GYMNASTS_QUERY] }, - ); - const [isFormOpen, setIsFormOpen] = useState(false); - const [name, setName] = useState(""); - const [error, setError] = useState(""); - const [announcement, setAnnouncement] = useState(""); - const nameInput = useRef(null); - const addButton = useRef(null); - const wasFormOpen = useRef(false); + ) + const [isFormOpen, setIsFormOpen] = useState(false) + const [name, setName] = useState('') + const [error, setError] = useState('') + const [announcement, setAnnouncement] = useState('') + const nameInput = useRef(null) + const addButton = useRef(null) + const wasFormOpen = useRef(false) // A second submit before the first has an answer (Enter twice) must not // create a second gymnast. The state of the mutation changes one render too late. - const isSubmitting = useRef(false); + const isSubmitting = useRef(false) // The same answer as the menu reads. Nobody logged in, or a session that has // ended, is not the same as a coach with no gymnasts, so it must not look like // an empty list. (A visitor who is not logged in does not get here from the // menu: ProtectedRoute sends them to the login page. This is for a session // that ends while the page is open.) - const isLoggedOut = useAuth().status === "anonymous"; - const [deleteError, setDeleteError] = useState(""); - const loadProblem = loadError ? describeError(loadError) : null; + const isLoggedOut = useAuth().status === 'anonymous' + const [deleteError, setDeleteError] = useState('') + const loadProblem = loadError ? describeError(loadError) : null useEffect(() => { if (isFormOpen) { - nameInput.current?.focus(); - wasFormOpen.current = true; + nameInput.current?.focus() + wasFormOpen.current = true } else if (wasFormOpen.current) { - addButton.current?.focus(); - wasFormOpen.current = false; + addButton.current?.focus() + wasFormOpen.current = false } - }, [isFormOpen]); + }, [isFormOpen]) // The button of the deleted gymnast is gone, so the focus goes to the button // that adds one, and the visitor is told what happened. function onDeleted(deletedName: string) { - setDeleteError(""); - setAnnouncement(`${deletedName} deleted.`); - addButton.current?.focus(); + setDeleteError('') + setAnnouncement(`${deletedName} deleted.`) + addButton.current?.focus() } function closeForm() { - setIsFormOpen(false); - setName(""); - setError(""); + setIsFormOpen(false) + setName('') + setError('') } async function addGymnast(event: React.FormEvent) { - event.preventDefault(); - if (isSubmitting.current) return; - const normalizedName = normalizeGymnastName(name); - const nameError = getGymnastNameError(name); + event.preventDefault() + if (isSubmitting.current) return + const normalizedName = normalizeGymnastName(name) + const nameError = getGymnastNameError(name) if (nameError) { - setError(nameError); - nameInput.current?.focus(); - return; + setError(nameError) + nameInput.current?.focus() + return } - isSubmitting.current = true; + isSubmitting.current = true try { const result = await createGymnast({ variables: { name: normalizedName }, - }); - const addedName = result.data?.createGymnast.name ?? normalizedName; - setAnnouncement(`${addedName} added to your gymnasts.`); - closeForm(); + }) + const addedName = result.data?.createGymnast.name ?? normalizedName + setAnnouncement(`${addedName} added to your gymnasts.`) + closeForm() } catch (problem) { // The form stays open with what the user wrote, so it can be corrected. - setError(describeError(problem).message); - nameInput.current?.focus(); + setError(describeError(problem).message) + nameInput.current?.focus() } finally { - isSubmitting.current = false; + isSubmitting.current = false } } @@ -107,7 +107,7 @@ export default function YourGymnastsPage() {

      {announcement}

      -
      +

      Your gymnasts

      {!isFormOpen && data?.me && ( @@ -257,5 +259,5 @@ export default function YourGymnastsPage() { )}
      - ); + ) } diff --git a/src/styles/global.css b/src/styles/global.css index 319c665..d49730c 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -1,5 +1,5 @@ #root .element-detail-page :is(h1, h2) { - color: var(--text-color-dark); + color: var(--text-color); } /* The pages use the whole width of the window, not the 1126px that #root has in @@ -9,9 +9,17 @@ border-inline: 0; } -/* The titles of the Elements, gymnasts and login pages are the same: the size +/* The titles of the Elements, gymnasts, gymnast, About and login pages are the same: the size of h1 comes from index.css (56px, 36px on a narrow window) for all of them. */ -#root :is(.elements-page, .your-gymnasts-page, .login-page) :is(h1, h2) { +#root + :is( + .elements-page, + .your-gymnasts-page, + .gymnast-page, + .about-page, + .login-page + ) + :is(h1, h2) { color: var(--text-color); font-weight: 700; letter-spacing: -0.01em; @@ -22,7 +30,11 @@ /* The text in the circle of the difficulty badge has the color of the text in "More variants" (--pill-text), in light and dark mode, so they match. Only the color changes; the badge itself is not changed. */ -#root .elements-page :is(td, dd) .inline-flex.shrink-0.flex-col > span:first-child { +#root + .elements-page + :is(td, dd) + .inline-flex.shrink-0.flex-col + > span:first-child { color: var(--pill-text); } @@ -30,13 +42,24 @@ be read on the dark table. Only that text changes, and only in dark mode (the circle and its letter keep their colors, and the badge is not changed). The selector has shrink-0, which is on the difficulty badge. */ -[data-theme='dark'] #root .elements-page :is(td, dd) .inline-flex.shrink-0.flex-col > span + span { +[data-theme='dark'] + #root + .elements-page + :is(td, dd) + .inline-flex.shrink-0.flex-col + > span + + span { color: var(--text-color); } /* The circle of the badge gets the light color of "More variants" in dark mode, with dark text, so it stands out from the dark rows. */ -[data-theme='dark'] #root .elements-page :is(td, dd) .inline-flex.shrink-0.flex-col > span:first-child { +[data-theme='dark'] + #root + .elements-page + :is(td, dd) + .inline-flex.shrink-0.flex-col + > span:first-child { background-color: var(--pill-bg); color: var(--pill-text); } @@ -52,8 +75,10 @@ margin-inline-end: -0.5rem; cursor: pointer; background-color: var(--text-color); - -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M18 6 6 18'/%3E%3Cpath d='m6 6 12 12'/%3E%3C/svg%3E") center / 1rem 1rem no-repeat; - mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M18 6 6 18'/%3E%3Cpath d='m6 6 12 12'/%3E%3C/svg%3E") center / 1rem 1rem no-repeat; + -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M18 6 6 18'/%3E%3Cpath d='m6 6 12 12'/%3E%3C/svg%3E") + center / 1rem 1rem no-repeat; + mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M18 6 6 18'/%3E%3Cpath d='m6 6 12 12'/%3E%3C/svg%3E") + center / 1rem 1rem no-repeat; } #root .elements-page h1 { @@ -66,8 +91,8 @@ margin-bottom: 0.75rem; } -#root .your-gymnasts-page h2 { - font-size: clamp(1.375rem, 4vw, 1.75rem); - line-height: 1.2; - margin: 0; +#root :is(.your-gymnasts-page, .gymnast-page, .about-page) h2 { + font-size: clamp(1.375rem, 4vw, 1.75rem); + line-height: 1.2; + margin: 0; } diff --git a/src/styles/variables.css b/src/styles/variables.css index aaf48c4..0f78d26 100644 --- a/src/styles/variables.css +++ b/src/styles/variables.css @@ -1,149 +1,164 @@ :root { - /* Color palette */ - --primary-color: #F0EDE7; - --secondary-color: #E0DFE4; - --accent-color: #C0D2F6; - --background-color: #ecf0f1; - --background-color-element: #F8F7F3; - --border-color: #EAE0CF; + /* Color palette */ + --primary-color: #f0ede7; + --secondary-color: #e0dfe4; + --accent-color: #c0d2f6; + --background-color: #ecf0f1; + --background-color-element: #f8f7f3; + --border-color: #eae0cf; - /* Text colors */ - --text-color: #101844; - --text-color-dark: #090F2F; - --text-color-light: #EAE0CF; + /* Text colors */ + --text-color: #101844; + --text-color-dark: #090f2f; + --text-color-light: #eae0cf; - /* Font settings */ - --font-family-title: 'Faustina', serif; - --font-family-text: 'Inter'; - --font-size-page-title: 42px; - --font-size-element-title: 19px; - --font-size-row-title: 15px; - --font-size-subtext: 13px; + /* Font settings */ + --font-family-title: 'Faustina', serif; + --font-family-text: 'Inter'; + --font-size-page-title: 42px; + --font-size-element-title: 19px; + --font-size-row-title: 15px; + --font-size-subtext: 13px; - /* Box settings */ - --corner-radius: 100px; - --box-color: #F0EDE7; - --box-stroke: #888394; - --box-shadow: 0 2px 5px rgba(3, 2, 2, 0.1); + /* Box settings */ + --corner-radius: 100px; + --box-color: #f0ede7; + --box-stroke: #888394; + --box-shadow: 0 2px 5px rgba(3, 2, 2, 0.1); - /* The Elements page. Each color has a light and a dark value (the dark + /* The Elements page. Each color has a light and a dark value (the dark ones are in the dark block below), so the page follows the theme. */ - --page-bg: var(--secondary-color); - --surface: var(--background-color-element); - /* The rows have no color change on hover in light mode (the design has none), and + --page-bg: var(--secondary-color); + --surface: var(--background-color-element); + /* The rows have no color change on hover in light mode (the design has none), and in dark mode they do. The buttons and the search field change in both. */ - --surface-hover: var(--surface); - --row-hover-amount: 0%; - /* Every other row in the results table: a light tint of the blue accent color */ - --table-row-alt: color-mix(in srgb, var(--background-color-element), var(--accent-color) 40%); - --table-head-bg: var(--text-color); - --table-head-text: var(--background-color-element); - --pill-bg: var(--text-color); - --pill-text: var(--background-color-element); - /* The buttons: light in the design. In dark mode they use the light color of + --surface-hover: var(--surface); + --row-hover-amount: 0%; + /* The hover of a card or a row in the lists of the gymnasts pages: a tint of the text color. */ + --card-hover: color-mix(in srgb, var(--surface), var(--text-color) 8%); + /* Every other row in the results table: a light tint of the blue accent color */ + --table-row-alt: color-mix( + in srgb, + var(--background-color-element), + var(--accent-color) 40% + ); + --table-head-bg: var(--text-color); + --table-head-text: var(--background-color-element); + --pill-bg: var(--text-color); + --pill-text: var(--background-color-element); + /* The buttons: light in the design. In dark mode they use the light color of "More variants", so they stand out from the dark page. */ - --button-bg: var(--surface); - --button-hover-bg: var(--primary-color); - --button-text: var(--text-color); - /* The search field: as the design in light mode. In dark mode it is see-through + --button-bg: var(--surface); + --button-hover-bg: var(--primary-color); + --button-text: var(--text-color); + /* The search field: as the design in light mode. In dark mode it is see-through with a tan line, so it can be seen without a solid color. */ - --search-bg: var(--surface); - --search-hover-bg: var(--primary-color); - --search-border: transparent; + --search-bg: var(--surface); + --search-hover-bg: var(--primary-color); + --search-border: transparent; - /* The gymnasts and the login pages. Each has a light and a dark value, so + /* The gymnasts and the login pages. Each has a light and a dark value, so these pages follow the theme like the Elements page does. */ - --avatar-bg: var(--accent-color); - --avatar-text: var(--text-color-dark); - /* Text fields: a solid field in light mode, see-through with a tan line in dark mode. */ - --field-bg: var(--background-color-element); - --field-border: var(--box-stroke); - /* Delete: red on the card (6.1:1 in light mode, 5.8:1 in dark mode), and the text + --avatar-bg: var(--accent-color); + --avatar-text: var(--text-color-dark); + /* Text fields: a solid field in light mode, see-through with a tan line in dark mode. */ + --field-bg: var(--background-color-element); + --field-border: var(--box-stroke); + /* Delete: red on the card (6.1:1 in light mode, 5.8:1 in dark mode), and the text on a red button. */ - --danger: #B42318; - --danger-contrast: #FFFFFF; - /* The save tick: green with a white tick (5.3:1), and 5.0:1 against the card, so + --danger: #b42318; + --danger-contrast: #ffffff; + /* The save tick: green with a white tick (5.3:1), and 5.0:1 against the card, so the button can be seen. The card color alone would not be enough. */ - --success-bg: #1E7B34; - --success-contrast: #FFFFFF; - /* The "No, keep it" button of the delete dialog. Its fill is close to the card + --success-bg: #1e7b34; + --success-contrast: #ffffff; + /* The "No, keep it" button of the delete dialog. Its fill is close to the card (1.2:1), so it has an edge of 2px that is at least 3:1 on the card (3.4:1). */ - --secondary-button-bg: var(--secondary-color); - --secondary-button-text: var(--text-color); - --secondary-button-border: var(--box-stroke); + --secondary-button-bg: var(--secondary-color); + --secondary-button-text: var(--text-color); + --secondary-button-border: var(--box-stroke); - /* D-value badge */ - --badge-d-value-bg: #BDE981; - --badge-d-value-text: var(--text-color-dark); - --border-color: var(--text-color); + /* D-value badge */ + --badge-d-value-bg: #bde981; + --badge-d-value-text: var(--text-color-dark); + --border-color: var(--text-color); - /* This part was generate with the help of Gemini 2.5 Flash using the prompt: + /* This part was generate with the help of Gemini 2.5 Flash using the prompt: "Create spacing variables and a dark mode colro pallette based on my previous design" */ - /* Space settings */ - --space-xs: 0.25rem; /* 4px - micro gaps (title to subtext) */ - --space-sm: 0.5rem; /* 8px - small padding (chips, badges) */ - --space-md: 1rem; /* 16px - default padding (inputs, row heights) */ - --space-lg: 1.5rem; /* 24px - component gaps (between search & filters) */ - --space-xl: 2.5rem; /* 40px - page section margins & outer padding */ + /* Space settings */ + --space-xs: 0.25rem; /* 4px - micro gaps (title to subtext) */ + --space-sm: 0.5rem; /* 8px - small padding (chips, badges) */ + --space-md: 1rem; /* 16px - default padding (inputs, row heights) */ + --space-lg: 1.5rem; /* 24px - component gaps (between search & filters) */ + --space-xl: 2.5rem; /* 40px - page section margins & outer padding */ } /* Dark mode colors */ [data-theme='dark'] { - /* Main Layout Colors */ - --primary-color: #0A1A3C; /* Deep blue for primary elements */ - --background-color: #090F2F; /* Dark blue background */ - --bg: var(--background-color); - --text: var(--text-color); - --header-background: #101844; /* Slightly lighter blue for nav */ - --element-background: #18225C; /* Inputs, dropdowns, table background */ - --surface-selected: #253788; /* Row selection tint */ + /* Main Layout Colors */ + --primary-color: #0a1a3c; /* Deep blue for primary elements */ + --background-color: #090f2f; /* Dark blue background */ + --bg: var(--background-color); + --text: var(--text-color); + --header-background: #101844; /* Slightly lighter blue for nav */ + --element-background: #18225c; /* Inputs, dropdowns, table background */ + --surface-selected: #253788; /* Row selection tint */ - /* Text Colors */ - --text-color: #EAE0CF; /* Headings and body text */ - --text-color-primary: #EAE0CF; /* Warm light text */ - --text-color-secondary: #A0AEC0; /* Muted text for labels */ - --text-color-on-dark: #090F2F; /* Inverted text for light buttons */ + /* Text Colors */ + --text-color: #eae0cf; /* Headings and body text */ + --text-color-primary: #eae0cf; /* Warm light text */ + --text-color-secondary: #a0aec0; /* Muted text for labels */ + --text-color-on-dark: #090f2f; /* Inverted text for light buttons */ - /* UI Components & Borders */ - --border-color: #3B426B; - --border-subtle: #2A3363; - --badge-d-value-bg: #82C43C; /* Slightly deeper green for contrast */ - --badge-d-value-text: #090F2F; + /* UI Components & Borders */ + --border-color: #3b426b; + --border-subtle: #2a3363; + --badge-d-value-bg: #82c43c; /* Slightly deeper green for contrast */ + --badge-d-value-text: #090f2f; - /* The Elements page, from the color inspiration (inspo.png): a gray slate + /* The Elements page, from the color inspiration (inspo.png): a gray slate backdrop, darker slate rows, the navbar color in the table heading and a warm tan for the "More variants" mark. */ - --page-bg: #40444F; - --surface: #2D3343; - --surface-hover: #434A60; - --row-hover-amount: 10%; - --table-row-alt: #373E52; - --table-head-bg: var(--primary-color); - --table-head-text: var(--text-color); - --pill-bg: #C19F83; - --pill-text: var(--primary-color); - --button-bg: var(--pill-bg); - --button-hover-bg: color-mix(in srgb, var(--pill-bg), white 25%); - --button-text: var(--pill-text); - --search-bg: color-mix(in srgb, var(--text-color) 10%, transparent); - --search-hover-bg: color-mix(in srgb, var(--text-color) 16%, transparent); - --search-border: var(--pill-bg); - --avatar-bg: var(--pill-bg); - --avatar-text: var(--primary-color); - --field-bg: color-mix(in srgb, var(--text-color) 10%, transparent); - --field-border: var(--pill-bg); - --danger: #FF8F8F; - --danger-contrast: #090F2F; - /* A mint green for the save tick in dark mode: 7.1:1 on the card, and the dark tick + --page-bg: #40444f; + --surface: #2d3343; + --surface-hover: #434a60; + --row-hover-amount: 10%; + /* Darker than the card, in the navy of the theme. The light tint of the text color was + 1.05:1 to the page (#40444f), so a row under the pointer looked like a hole in the + card; this is 1.67:1 to the page and 1.29:1 to the card at rest, and the text on it is + 12.4:1. */ + --card-hover: color-mix( + in srgb, + var(--surface), + var(--text-color-on-dark) 55% + ); + --table-row-alt: #373e52; + --table-head-bg: var(--primary-color); + --table-head-text: var(--text-color); + --pill-bg: #c19f83; + --pill-text: var(--primary-color); + --button-bg: var(--pill-bg); + --button-hover-bg: color-mix(in srgb, var(--pill-bg), white 25%); + --button-text: var(--pill-text); + --search-bg: color-mix(in srgb, var(--text-color) 10%, transparent); + --search-hover-bg: color-mix(in srgb, var(--text-color) 16%, transparent); + --search-border: var(--pill-bg); + --avatar-bg: var(--pill-bg); + --avatar-text: var(--primary-color); + --field-bg: color-mix(in srgb, var(--text-color) 10%, transparent); + --field-border: var(--pill-bg); + --danger: #ff8f8f; + --danger-contrast: #090f2f; + /* A mint green for the save tick in dark mode: 7.1:1 on the card, and the dark tick on it is 10.6:1. */ - --success-bg: #01DF80; - --success-contrast: #090F2F; - /* White text (7.1:1) on a dark fill. White text and a fill that is 3:1 on the + --success-bg: #01df80; + --success-contrast: #090f2f; + /* White text (7.1:1) on a dark fill. White text and a fill that is 3:1 on the card cannot both be had (the fill would have to be both darker and lighter than a luminance of 0.19), so the edge of the control is a blue-grey line of 2px, 3.6:1 on the card, as the rule for the edge of a control asks (WCAG 1.4.11). */ - --secondary-button-bg: #4F5778; - --secondary-button-text: #FFFFFF; - --secondary-button-border: #8088A6; /* a blue-grey, 3.6:1 on the card: it does not draw the eye first */ + --secondary-button-bg: #4f5778; + --secondary-button-text: #ffffff; + --secondary-button-border: #8088a6; /* a blue-grey, 3.6:1 on the card: it does not draw the eye first */ } diff --git a/src/App.flow.test.tsx b/src/tests/App.flow.test.tsx similarity index 99% rename from src/App.flow.test.tsx rename to src/tests/App.flow.test.tsx index f834e55..5052ed9 100644 --- a/src/App.flow.test.tsx +++ b/src/tests/App.flow.test.tsx @@ -18,7 +18,7 @@ import { import { BrowserRouter } from 'react-router-dom' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createTestClient, ok, type Handler } from '@/tests/apollo' -import App from './App' +import App from '@/App' const BASE = import.meta.env.BASE_URL diff --git a/src/App.test.tsx b/src/tests/App.test.tsx similarity index 53% rename from src/App.test.tsx rename to src/tests/App.test.tsx index 74e8b3a..8e30fe8 100644 --- a/src/App.test.tsx +++ b/src/tests/App.test.tsx @@ -1,10 +1,10 @@ // @vitest-environment jsdom -import { ApolloProvider } from '@apollo/client/react'; -import { cleanup, fireEvent, render, screen } from '@testing-library/react'; -import { BrowserRouter } from 'react-router-dom'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { createTestClient, ok, type Handler } from '@/tests/apollo'; -import App from './App'; +import { ApolloProvider } from '@apollo/client/react' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { BrowserRouter } from 'react-router-dom' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createTestClient, ok, type Handler } from '@/tests/apollo' +import App from '@/App' /** * The app with the router and the Apollo client that main.tsx gives it. The real @@ -12,124 +12,175 @@ import App from './App'; * answers, so the pages stay in their loading state. */ const renderApp = (handler: Handler = () => new Promise(() => {})) => { - const { client } = createTestClient(handler); + const { client } = createTestClient(handler) return render( , - ); -}; + ) +} beforeEach(() => { - window.localStorage.clear(); - window.history.replaceState({}, '', import.meta.env.BASE_URL); -}); + window.localStorage.clear() + window.history.replaceState({}, '', import.meta.env.BASE_URL) +}) afterEach(() => { - delete document.documentElement.dataset.theme; - window.localStorage.clear(); - window.history.replaceState({}, '', import.meta.env.BASE_URL); -}); + vi.unstubAllGlobals() + delete document.documentElement.dataset.theme + window.localStorage.clear() + window.history.replaceState({}, '', import.meta.env.BASE_URL) +}) describe('App', () => { it('applies and toggles the selected theme on the document', () => { - renderApp(); + renderApp() - expect(document.documentElement.getAttribute('data-theme')).toBe('light'); - expect(window.localStorage.getItem('theme')).toBe('light'); + expect(document.documentElement.getAttribute('data-theme')).toBe('light') + expect(window.localStorage.getItem('theme')).toBe('light') - const themeToggle = screen.getByRole('switch', { name: /toggle dark theme/i }); - fireEvent.click(themeToggle); + const themeToggle = screen.getByRole('switch', { + name: /toggle dark theme/i, + }) + fireEvent.click(themeToggle) - expect(document.documentElement.getAttribute('data-theme')).toBe('dark'); - expect(window.localStorage.getItem('theme')).toBe('dark'); + expect(document.documentElement.getAttribute('data-theme')).toBe('dark') + expect(window.localStorage.getItem('theme')).toBe('dark') - fireEvent.click(themeToggle); + fireEvent.click(themeToggle) - expect(document.documentElement.getAttribute('data-theme')).toBe('light'); - expect(window.localStorage.getItem('theme')).toBe('light'); - }); + expect(document.documentElement.getAttribute('data-theme')).toBe('light') + expect(window.localStorage.getItem('theme')).toBe('light') + }) it('restores the previously saved theme on page load', () => { - window.localStorage.setItem('theme', 'dark'); + window.localStorage.setItem('theme', 'dark') - renderApp(); + renderApp() - expect(document.documentElement.getAttribute('data-theme')).toBe('dark'); + expect(document.documentElement.getAttribute('data-theme')).toBe('dark') expect( screen .getByRole('switch', { name: /toggle dark theme/i }) .getAttribute('aria-checked'), - ).toBe('true'); - }); + ).toBe('true') + }) + + it('starts from the system colour scheme when no theme is stored, and a stored theme wins', () => { + const systemIsDark = (dark: boolean) => + vi.stubGlobal('matchMedia', () => ({ matches: dark })) + + systemIsDark(true) + renderApp() + expect(document.documentElement.getAttribute('data-theme')).toBe('dark') + cleanup() + + window.localStorage.setItem('theme', 'light') + renderApp() + expect(document.documentElement.getAttribute('data-theme')).toBe('light') + }) it('generates navigation URLs beneath the configured base path', () => { - renderApp(); + renderApp() - expect(screen.getByRole('link', { name: 'Calculator' }).getAttribute('href')).toBe( - `${import.meta.env.BASE_URL}calculator`, - ); - }); + expect( + screen.getByRole('link', { name: 'Calculator' }).getAttribute('href'), + ).toBe(`${import.meta.env.BASE_URL}calculator`) + }) it('renders routed content inside a single main landmark', () => { - renderApp(); + renderApp() + + expect(screen.getAllByRole('main')).toHaveLength(1) + + fireEvent.click(screen.getByRole('link', { name: 'About' })) - expect(screen.getAllByRole('main')).toHaveLength(1); + expect(screen.getByRole('heading', { name: 'About' })).toBeTruthy() + expect(screen.getAllByRole('main')).toHaveLength(1) + }) - fireEvent.click(screen.getByRole('link', { name: 'About' })); + it('ends every page with the link back to the top, which comes after the page', () => { + renderApp() - expect(screen.getByRole('heading', { name: 'About' })).toBeTruthy(); - expect(screen.getAllByRole('main')).toHaveLength(1); - }); + for (const name of ['Search', 'About', 'Calculator']) { + fireEvent.click(screen.getByRole('link', { name })) + + const top = screen.getByRole('link', { + name: 'Go to the top of the page', + }) + expect( + screen.getByRole('main').compareDocumentPosition(top) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy() + } + }) it('gives every page its own title, so the tab and the history say where you are', () => { - renderApp(); - expect(document.title).toBe('Elements – WAG CoP Planner'); + renderApp() + expect(document.title).toBe('Elements – WAG CoP Planner') - fireEvent.click(screen.getByRole('link', { name: 'About' })); - expect(document.title).toBe('About – WAG CoP Planner'); + fireEvent.click(screen.getByRole('link', { name: 'About' })) + expect(document.title).toBe('About – WAG CoP Planner') - fireEvent.click(screen.getByRole('link', { name: 'Calculator' })); - expect(document.title).toBe('Calculator – WAG CoP Planner'); + fireEvent.click(screen.getByRole('link', { name: 'Calculator' })) + expect(document.title).toBe('Calculator – WAG CoP Planner') - window.history.pushState({}, '', `${import.meta.env.BASE_URL}no-such-page`); - cleanup(); - renderApp(); - expect(document.title).toBe('Page not found – WAG CoP Planner'); - }); + window.history.pushState({}, '', `${import.meta.env.BASE_URL}no-such-page`) + cleanup() + renderApp() + expect(document.title).toBe('Page not found – WAG CoP Planner') + }) it('opens the search page from the start address, the logo and the Search link', () => { - renderApp(); - expect(window.location.pathname).toBe(`${import.meta.env.BASE_URL}elements`); - expect(screen.getByRole('heading', { level: 1, name: 'Elements' })).toBeTruthy(); + renderApp() + expect(window.location.pathname).toBe(`${import.meta.env.BASE_URL}elements`) + expect( + screen.getByRole('heading', { level: 1, name: 'Elements' }), + ).toBeTruthy() for (const link of [ () => screen.getByRole('link', { name: /wag cop planner home/i }), () => screen.getByRole('link', { name: 'Search' }), ]) { - fireEvent.click(screen.getByRole('link', { name: 'About' })); - expect(screen.getByRole('heading', { name: 'About' })).toBeTruthy(); - - fireEvent.click(link()); - expect(window.location.pathname).toBe(`${import.meta.env.BASE_URL}elements`); - expect(screen.getByRole('heading', { level: 1, name: 'Elements' })).toBeTruthy(); + fireEvent.click(screen.getByRole('link', { name: 'About' })) + expect(screen.getByRole('heading', { name: 'About' })).toBeTruthy() + + fireEvent.click(link()) + expect(window.location.pathname).toBe( + `${import.meta.env.BASE_URL}elements`, + ) + expect( + screen.getByRole('heading', { level: 1, name: 'Elements' }), + ).toBeTruthy() } - }); + }) it('sends a visitor who is not logged in from the Gymnasts route to the login page', async () => { - window.history.replaceState({}, '', `${import.meta.env.BASE_URL}gymnasts`); - renderApp(() => ok({ me: null })); - - expect(await screen.findByRole('heading', { name: 'Log in' })).toBeTruthy(); - expect(window.location.pathname).toBe(`${import.meta.env.BASE_URL}login`); - expect(screen.queryByRole('link', { name: 'Gymnasts' })).toBeNull(); - expect(screen.getByRole('link', { name: 'Log in' })).toBeTruthy(); - }); + window.history.replaceState({}, '', `${import.meta.env.BASE_URL}gymnasts`) + renderApp(() => ok({ me: null })) + + expect(await screen.findByRole('heading', { name: 'Log in' })).toBeTruthy() + expect(window.location.pathname).toBe(`${import.meta.env.BASE_URL}login`) + expect(screen.queryByRole('link', { name: 'Gymnasts' })).toBeNull() + expect(screen.getByRole('link', { name: 'Log in' })).toBeTruthy() + }) + + it('sends a visitor who is not logged in from the page of one gymnast to the login page', async () => { + window.history.replaceState( + {}, + '', + `${import.meta.env.BASE_URL}gymnasts/gymnast-1`, + ) + renderApp(() => ok({ me: null })) + + expect(await screen.findByRole('heading', { name: 'Log in' })).toBeTruthy() + expect(window.location.pathname).toBe(`${import.meta.env.BASE_URL}login`) + }) it('keeps a logged-in coach on the Gymnasts route after a reload, and the menu agrees with the page', async () => { - window.history.replaceState({}, '', `${import.meta.env.BASE_URL}gymnasts`); + window.history.replaceState({}, '', `${import.meta.env.BASE_URL}gymnasts`) renderApp((operation) => ok({ me: { @@ -142,27 +193,27 @@ describe('App', () => { }), }, }), - ); + ) expect( await screen.findByRole('heading', { name: 'Your gymnasts' }), - ).toBeTruthy(); - expect(window.location.pathname).toBe(`${import.meta.env.BASE_URL}gymnasts`); + ).toBeTruthy() + expect(window.location.pathname).toBe(`${import.meta.env.BASE_URL}gymnasts`) // The menu and the page read the same answer. - expect(screen.getByRole('button', { name: 'Log out' })).toBeTruthy(); - expect(screen.queryByRole('link', { name: 'Log in' })).toBeNull(); - expect(screen.getByRole('link', { name: 'Gymnasts' })).toBeTruthy(); - expect(await screen.findByText('Emma Berg')).toBeTruthy(); - }); + expect(screen.getByRole('button', { name: 'Log out' })).toBeTruthy() + expect(screen.queryByRole('link', { name: 'Log in' })).toBeNull() + expect(screen.getByRole('link', { name: 'Gymnasts' })).toBeTruthy() + expect(await screen.findByText('Emma Berg')).toBeTruthy() + }) it('does not send a visitor away before the server has said who is logged in', async () => { - window.history.replaceState({}, '', `${import.meta.env.BASE_URL}gymnasts`); - renderApp(); // the server never answers + window.history.replaceState({}, '', `${import.meta.env.BASE_URL}gymnasts`) + renderApp() // the server never answers - expect(await screen.findByText('Checking your login…')).toBeTruthy(); - expect(window.location.pathname).toBe(`${import.meta.env.BASE_URL}gymnasts`); + expect(await screen.findByText('Checking your login…')).toBeTruthy() + expect(window.location.pathname).toBe(`${import.meta.env.BASE_URL}gymnasts`) // Neither "Log in" nor "Log out" is true yet. - expect(screen.queryByRole('link', { name: 'Log in' })).toBeNull(); - expect(screen.queryByRole('button', { name: 'Log out' })).toBeNull(); - }); -}); + expect(screen.queryByRole('link', { name: 'Log in' })).toBeNull() + expect(screen.queryByRole('button', { name: 'Log out' })).toBeNull() + }) +}) diff --git a/src/tests/components/ConfirmDialog.test.tsx b/src/tests/components/ConfirmDialog.test.tsx new file mode 100644 index 0000000..bddaf17 --- /dev/null +++ b/src/tests/components/ConfirmDialog.test.tsx @@ -0,0 +1,160 @@ +// @vitest-environment jsdom +// The dialog that asks "are you sure?" before something is removed (WCAG 3.3.4). + +import { useRef, useState } from 'react' +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import ConfirmDialog from '@/components/ConfirmDialog' + +afterEach(cleanup) + +/** The page that owns the dialog: a button opens it, as the Gymnast page does. */ +function Page({ + onConfirm, + focusElsewhere, +}: { + onConfirm: () => Promise + focusElsewhere: boolean +}) { + const [open, setOpen] = useState(false) + const elsewhere = useRef(null) + return ( + <> + +

      + Somewhere else +

      + elsewhere.current : undefined} + /> + + ) +} + +const renderDialog = ( + onConfirm: () => Promise = () => Promise.resolve(), + focusElsewhere = false, +) => render() + +const open = async () => { + const opener = screen.getByRole('button', { name: 'Remove it' }) + opener.focus() // a click focuses a button in the browser; fireEvent does not + fireEvent.click(opener) + return screen.findByRole('alertdialog') +} + +describe('ConfirmDialog', () => { + it('is closed until the page opens it', () => { + renderDialog() + + expect(screen.queryByRole('alertdialog')).toBeNull() + }) + + it('has a title and a description that are read as the name and description of the dialog', async () => { + renderDialog() + + const dialog = await open() + expect( + screen.getByRole('alertdialog', { name: 'Remove element' }), + ).toBeTruthy() + expect(dialog.getAttribute('aria-describedby')).toBeTruthy() + expect(dialog.textContent).toContain('1.302 will be removed from the list.') + }) + + it('puts the focus on the answer that does nothing, so Enter right away does not remove', async () => { + renderDialog() + await open() + + await waitFor(() => + expect(document.activeElement).toBe( + screen.getByRole('button', { name: 'No, keep it' }), + ), + ) + }) + + it('closes with Escape, does not confirm, and gives the focus back to the button that opened it', async () => { + const onConfirm = vi.fn(() => Promise.resolve()) + renderDialog(onConfirm) + await open() + + fireEvent.keyDown(screen.getByRole('alertdialog'), { key: 'Escape' }) + + await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull()) + expect(onConfirm).not.toHaveBeenCalled() + await waitFor(() => + expect(document.activeElement).toBe( + screen.getByRole('button', { name: 'Remove it' }), + ), + ) + }) + + it('calls onConfirm once and closes when it is done', async () => { + const onConfirm = vi.fn(() => Promise.resolve()) + renderDialog(onConfirm) + await open() + + fireEvent.click(screen.getByRole('button', { name: 'Yes, remove' })) + + await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull()) + expect(onConfirm).toHaveBeenCalledTimes(1) + }) + + it('moves the focus where finalFocus says when it closes', async () => { + renderDialog(() => Promise.resolve(), true) + await open() + + fireEvent.click(screen.getByRole('button', { name: 'Yes, remove' })) + + await waitFor(() => + expect(document.activeElement).toBe( + screen.getByRole('heading', { name: 'Somewhere else' }), + ), + ) + }) + + it('shows the busy text and cannot be confirmed again while it works', async () => { + let finish!: () => void + const onConfirm = vi.fn( + () => new Promise((resolve) => (finish = resolve)), + ) + renderDialog(onConfirm) + await open() + + fireEvent.click(screen.getByRole('button', { name: 'Yes, remove' })) + const busy = await screen.findByRole('button', { name: 'Removing…' }) + fireEvent.click(busy) + + expect( + busy.hasAttribute('disabled') || + busy.getAttribute('aria-disabled') === 'true', + ).toBe(true) + expect(onConfirm).toHaveBeenCalledTimes(1) + finish() + await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull()) + }) + + it('closes also when onConfirm fails, so the page can say what happened', async () => { + const onConfirm = vi.fn(() => Promise.reject(new Error('no'))) + renderDialog(onConfirm) + await open() + + fireEvent.click(screen.getByRole('button', { name: 'Yes, remove' })) + + await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull()) + }) +}) diff --git a/src/components/ProtectedRoute.test.tsx b/src/tests/components/ProtectedRoute.test.tsx similarity index 97% rename from src/components/ProtectedRoute.test.tsx rename to src/tests/components/ProtectedRoute.test.tsx index d45ab01..4cc2749 100644 --- a/src/components/ProtectedRoute.test.tsx +++ b/src/tests/components/ProtectedRoute.test.tsx @@ -3,7 +3,7 @@ import { cleanup, render, screen } from '@testing-library/react' import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom' import { afterEach, describe, expect, it } from 'vitest' import { AuthContext, type AuthState } from '@/components/auth/useAuth' -import { ProtectedRoute } from './ProtectedRoute' +import { ProtectedRoute } from '@/components/ProtectedRoute' afterEach(cleanup) diff --git a/src/tests/components/elements/AddToGymnast.test.tsx b/src/tests/components/elements/AddToGymnast.test.tsx new file mode 100644 index 0000000..d28a893 --- /dev/null +++ b/src/tests/components/elements/AddToGymnast.test.tsx @@ -0,0 +1,264 @@ +// @vitest-environment jsdom +// The button runs on the real Apollo Client, with a stub for `fetch` (see src/tests/apollo.ts). + +import { ApolloProvider } from '@apollo/client/react' +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react' +import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { AuthProvider } from '@/components/auth/AuthProvider' +import AddToGymnast from '@/components/elements/AddToGymnast' +import type { ElementDetail } from '@/lib/catalog' +import { + createTestClient, + deferred, + failure, + ok, + type Answer, + type Operation, +} from '@/tests/apollo' + +afterEach(cleanup) + +const element: ElementDetail = { + id: 'UB-2.305-a', + code: '2.305', + name: null, + description: 'Clear hip circle to hstd', + value: 0.3, + isVault: false, + apparatusId: 'UB', + apparatusName: 'Uneven bars', + elementGroupName: 'Casts and clear hip circles', + elementGroupNumber: 2, + imageUrl: 'http://localhost:3001/images/UB/UB-2.305-a.webp', + removed: false, + variants: [], +} + +const coach = { + __typename: 'Coach', + id: 'coach-1', + name: 'Anna', + email: 'anna@example.com', +} + +/** Shows where the visitor was sent and what was in the state of the navigation. */ +function LoginProbe() { + const location = useLocation() + return

      {JSON.stringify(location.state)}

      +} + +function renderButton(options: { + me?: object | null + me_answer?: Promise + gymnasts?: { id: string; name: string }[] + add?: (operation: Operation) => Answer | Promise + removed?: boolean +}) { + const onAdded = vi.fn() + const onFailed = vi.fn() + const { client, calls } = createTestClient((operation) => { + switch (operation.operationName) { + case 'Me': + return options.me_answer ?? ok({ me: options.me ?? null }) + case 'YourGymnasts': + return ok({ + me: { + ...coach, + gymnasts: (options.gymnasts ?? []).map((g) => ({ + __typename: 'Gymnast', + ...g, + })), + }, + }) + case 'AddGymnastElement': + return ( + options.add?.(operation) ?? + ok({ + addGymnastElement: { + __typename: 'Gymnast', + id: String(operation.variables['gymnastId']), + elements: [], + }, + }) + ) + default: + return failure('INTERNAL_SERVER_ERROR', 'unexpected') + } + }) + render( + + + + + + } + /> + } /> + + + + , + ) + return { + onAdded, + onFailed, + adds: () => calls.filter((c) => c.operationName === 'AddGymnastElement'), + } +} + +/** A click on the button opens the menu (Base UI opens it on a click, a mouse down or the arrow key). */ +async function openMenu() { + // The button is disabled until the server has said who is logged in, so wait for the one that works. + const trigger = await waitFor(() => { + const button = screen.getByRole('button', { + name: 'Add to gymnast', + }) as HTMLButtonElement + expect(button.disabled).toBe(false) + return button + }) + fireEvent.click(trigger) + return screen.findByRole('menu') +} + +describe('AddToGymnast: not logged in', () => { + it('is a link to the login with a lock, and it is not disabled', async () => { + renderButton({ me: null }) + + const link = await screen.findByRole('link', { + name: /^Add to gymnast/, + }) + expect(link.getAttribute('href')).toBe('/login') + expect(link.querySelector('svg')).not.toBeNull() + expect(link.getAttribute('aria-disabled')).toBeNull() + }) + + it('says to a screen reader that the login comes first', async () => { + renderButton({ me: null }) + + const link = await screen.findByRole('link', { name: /^Add to gymnast/ }) + expect(link.textContent).toContain('(log in first)') + }) + + it('sends the visitor back to this page, with its address, after the login', async () => { + renderButton({ me: null }) + + fireEvent.click( + await screen.findByRole('link', { name: /^Add to gymnast/ }), + ) + + expect( + JSON.parse((await screen.findByTestId('login')).textContent!), + ).toEqual({ + from: { pathname: '/elements/UB-2.305-a', search: '?from=search' }, + }) + }) +}) + +describe('AddToGymnast: waiting for the login, and a removed element', () => { + it('is a disabled button until the server has said who is logged in', async () => { + const answer = deferred() + renderButton({ me_answer: answer.promise }) + + const button = screen.getByRole('button', { + name: 'Add to gymnast', + }) as HTMLButtonElement + expect(button.disabled).toBe(true) + expect(screen.queryByRole('link', { name: /Add to gymnast/ })).toBeNull() + + answer.resolve(ok({ me: null })) + expect( + await screen.findByRole('link', { name: /^Add to gymnast/ }), + ).toBeTruthy() + }) + + it('is a disabled button for a removed element, also when nobody is logged in', async () => { + renderButton({ me: null, removed: true }) + + const button = (await screen.findByRole('button', { + name: 'Add to gymnast', + })) as HTMLButtonElement + expect(button.disabled).toBe(true) + expect(screen.queryByRole('link', { name: /Add to gymnast/ })).toBeNull() + }) +}) + +describe('AddToGymnast: logged in', () => { + const gymnasts = [ + { id: 'g-1', name: 'Ingrid Nilsen' }, + { id: 'g-2', name: 'Emma Berg' }, + ] + + it('opens a menu with the gymnasts of the coach', async () => { + renderButton({ me: coach, gymnasts }) + + await openMenu() + + expect( + (await screen.findAllByRole('menuitem')).map((item) => item.textContent), + ).toEqual(['Ingrid Nilsen', 'Emma Berg']) + }) + + it('adds the element to the gymnast that is chosen, and tells which', async () => { + const page = renderButton({ me: coach, gymnasts }) + + await openMenu() + fireEvent.click(await screen.findByRole('menuitem', { name: 'Emma Berg' })) + + await waitFor(() => + expect(page.onAdded).toHaveBeenCalledWith('2.305 added to Emma Berg.'), + ) + expect(page.adds()[0]?.variables).toEqual({ + gymnastId: 'g-2', + itemId: 'UB-2.305-a', + }) + expect(page.onFailed).not.toHaveBeenCalled() + }) + + it('tells why when the server refuses (a gymnast that is full)', async () => { + const page = renderButton({ + me: coach, + gymnasts, + add: () => + failure('LIMIT_REACHED', 'A gymnast can have at most 100 elements.'), + }) + + await openMenu() + fireEvent.click( + await screen.findByRole('menuitem', { name: 'Ingrid Nilsen' }), + ) + + await waitFor(() => + expect(page.onFailed).toHaveBeenCalledWith( + 'A gymnast can have at most 100 elements.', + ), + ) + expect(page.onAdded).not.toHaveBeenCalled() + }) + + it('says that there are no gymnasts, and links to the page where one is made', async () => { + renderButton({ me: coach, gymnasts: [] }) + + await openMenu() + + expect(await screen.findByText('You have no gymnasts yet.')).toBeTruthy() + expect( + screen + .getByRole('menuitem', { name: 'Go to your gymnasts' }) + .getAttribute('href'), + ).toBe('/gymnasts') + }) +}) diff --git a/src/tests/components/elements/ElementActions.test.tsx b/src/tests/components/elements/ElementActions.test.tsx new file mode 100644 index 0000000..25387bb --- /dev/null +++ b/src/tests/components/elements/ElementActions.test.tsx @@ -0,0 +1,78 @@ +// @vitest-environment jsdom + +import { ApolloProvider } from '@apollo/client/react' +import { cleanup, render, screen } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import { afterEach, describe, expect, it } from 'vitest' +import { AuthProvider } from '@/components/auth/AuthProvider' +import ElementActions from '@/components/elements/ElementActions' +import type { ElementDetail } from '@/lib/catalog' +import { createTestClient, ok } from '@/tests/apollo' + +afterEach(cleanup) + +const element: ElementDetail = { + id: 'UB-2.305-a', + code: '2.305', + name: null, + description: 'Clear hip circle to hstd', + value: 0.3, + isVault: false, + apparatusId: 'UB', + apparatusName: 'Uneven bars', + elementGroupName: 'Casts and clear hip circles', + elementGroupNumber: 2, + imageUrl: 'http://localhost:4000/images/UB/UB-2.305-a.webp', + removed: false, + variants: [], +} + +/** Nobody is logged in (`Me` answers null). */ +function renderActions(detail: ElementDetail) { + const { client } = createTestClient(() => ok({ me: null })) + return render( + + + + + + + , + ) +} + +const disabledButtons = () => + screen + .getAllByRole('button') + .filter((button) => (button as HTMLButtonElement).disabled) + +describe('ElementActions', () => { + it('turns off both buttons for a removed element, since the API refuses it', async () => { + renderActions({ ...element, removed: true }) + + expect( + screen.getByRole('button', { name: 'Try in a routine' }), + ).toHaveProperty('disabled', true) + expect( + await screen.findByRole('button', { name: 'Add to gymnast' }), + ).toHaveProperty('disabled', true) + expect(disabledButtons()).toHaveLength(2) + }) + + it('has no disabled button for an element that is in the Code of Points', async () => { + renderActions(element) + + await screen.findByRole('link', { name: /^Add to gymnast/ }) + expect(disabledButtons()).toHaveLength(0) + }) + + it('links both disabled buttons to the reason, so a screen reader reads it', async () => { + renderActions({ ...element, removed: true }) + + const reason = screen.getByText(/A removed element cannot be added/) + for (const name of ['Try in a routine', 'Add to gymnast']) { + const button = await screen.findByRole('button', { name }) + expect(button.getAttribute('aria-describedby')).toBe(reason.id) + } + }) +}) diff --git a/src/components/elements/ElementDetailHeader.test.tsx b/src/tests/components/elements/ElementDetailHeader.test.tsx similarity index 87% rename from src/components/elements/ElementDetailHeader.test.tsx rename to src/tests/components/elements/ElementDetailHeader.test.tsx index 83331e5..665cd31 100644 --- a/src/components/elements/ElementDetailHeader.test.tsx +++ b/src/tests/components/elements/ElementDetailHeader.test.tsx @@ -2,7 +2,7 @@ import { renderToString } from 'react-dom/server' import { MemoryRouter } from 'react-router-dom' import { describe, expect, it } from 'vitest' import type { ElementDetail } from '@/lib/catalog' -import ElementDetailHeader from './ElementDetailHeader' +import ElementDetailHeader from '@/components/elements/ElementDetailHeader' const element: ElementDetail = { id: 'UB-2.305-a', @@ -15,7 +15,7 @@ const element: ElementDetail = { apparatusName: 'Uneven bars', elementGroupName: 'Giant circles', elementGroupNumber: 3, - imageUrl: 'http://localhost:3001/images/UB/UB-2.305-a.webp', + imageUrl: 'http://localhost:4000/images/UB/UB-2.305-a.webp', removed: false, variants: [], } diff --git a/src/components/elements/ResultsTable.test.tsx b/src/tests/components/elements/ResultsTable.test.tsx similarity index 99% rename from src/components/elements/ResultsTable.test.tsx rename to src/tests/components/elements/ResultsTable.test.tsx index ecab212..2e71d7a 100644 --- a/src/components/elements/ResultsTable.test.tsx +++ b/src/tests/components/elements/ResultsTable.test.tsx @@ -3,7 +3,7 @@ import { MemoryRouter } from 'react-router-dom' import { describe, expect, it } from 'vitest' import { groupLabel, type ElementListRow } from '@/lib/elementList' import { exampleElementRows } from '@/tests/elementRows' -import ResultsTable from './ResultsTable' +import ResultsTable from '@/components/elements/ResultsTable' const render = (rows: ElementListRow[]) => renderToString( diff --git a/src/tests/components/errors.test.tsx b/src/tests/components/errors.test.tsx new file mode 100644 index 0000000..8f12872 --- /dev/null +++ b/src/tests/components/errors.test.tsx @@ -0,0 +1,76 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import ErrorMessage from '@/components/ErrorMessage' +import ErrorPage from '@/components/ErrorPage' +import ErrorPanel from '@/components/ErrorPanel' + +afterEach(cleanup) + +describe('ErrorMessage', () => { + it('is an alert with the text, and an icon that a screen reader skips', () => { + render(Something broke.) + + const alert = screen.getByRole('alert') + expect(alert.textContent).toBe('Something broke.') + expect(alert.querySelector('svg')?.getAttribute('aria-hidden')).toBe('true') + }) + + it('takes an id, so a field can point at it with aria-describedby', () => { + render(Too long.) + + expect(screen.getByRole('alert').id).toBe('name-error') + }) +}) + +describe('ErrorPanel', () => { + it('has the message as its only alert, with the buttons outside it', () => { + render( + {}} + action={Log in} + />, + ) + + const alert = screen.getByRole('alert') + expect(alert.textContent).toBe('Could not reach the server.') + expect( + alert.contains(screen.getByRole('button', { name: 'Try again' })), + ).toBe(false) + expect(screen.getByRole('link', { name: 'Log in' })).toBeTruthy() + }) + + it('calls onRetry when "Try again" is pressed', () => { + const onRetry = vi.fn() + render() + + fireEvent.click(screen.getByRole('button', { name: 'Try again' })) + + expect(onRetry).toHaveBeenCalledTimes(1) + }) + + it('has no "Try again" when the error cannot be tried again, or there is nothing to call', () => { + const { rerender } = render( + {}} />, + ) + expect(screen.queryByRole('button', { name: 'Try again' })).toBeNull() + + rerender() + expect(screen.queryByRole('button', { name: 'Try again' })).toBeNull() + }) +}) + +describe('ErrorPage', () => { + it('has the title of the page, the h1, and the message', () => { + render() + + expect( + screen.getByRole('heading', { level: 1, name: 'Gymnast unavailable' }), + ).toBeTruthy() + expect(screen.getByRole('alert').textContent).toBe('Try later.') + expect(document.title).toBe('Gymnast unavailable – WAG CoP Planner') + }) +}) diff --git a/src/tests/components/goToTop/GoToTop.test.tsx b/src/tests/components/goToTop/GoToTop.test.tsx new file mode 100644 index 0000000..6937aa9 --- /dev/null +++ b/src/tests/components/goToTop/GoToTop.test.tsx @@ -0,0 +1,46 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { GoToTop } from '@/components/goToTop/GoToTop' + +afterEach(cleanup) + +const LINK = { name: 'Go to the top of the page' } + +describe('GoToTop', () => { + it('is a link that is read by a screen reader but not seen until it has the focus', () => { + render() + + const link = screen.getByRole('link', LINK) + expect(link.className).toContain('sr-only') + expect(link.className).toContain('focus:not-sr-only') + }) + + it('scrolls to the top and puts the focus on the first thing that can be reached', () => { + const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => {}) + render( + <> + Start of the page + + , + ) + + fireEvent.click(screen.getByRole('link', LINK)) + + expect(scrollTo).toHaveBeenCalledWith({ top: 0 }) + expect(document.activeElement).toBe( + screen.getByRole('link', { name: 'Start of the page' }), + ) + scrollTo.mockRestore() + }) + + it('does not follow the link, so the address and the history stay as they are', () => { + vi.spyOn(window, 'scrollTo').mockImplementation(() => {}) + render() + + const notCancelled = fireEvent.click(screen.getByRole('link', LINK)) + + // fireEvent gives false when something called preventDefault. + expect(notCancelled).toBe(false) + }) +}) diff --git a/src/tests/components/gymnasts/AddButton.test.tsx b/src/tests/components/gymnasts/AddButton.test.tsx new file mode 100644 index 0000000..ee47993 --- /dev/null +++ b/src/tests/components/gymnasts/AddButton.test.tsx @@ -0,0 +1,24 @@ +// @vitest-environment jsdom + +import { cleanup, render, screen } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import { afterEach, describe, expect, it } from 'vitest' +import AddButton from '@/components/gymnasts/AddButton' + +afterEach(cleanup) + +describe('AddButton', () => { + it('is a link to the address with a plus before the text, and the plus is hidden from a screen reader', () => { + render( + + New routine + , + ) + + const link = screen.getByRole('link', { name: 'New routine' }) + expect(link.getAttribute('href')).toBe('/calculator?gymnast=g-1') + // A link that goes to another page: a screen reader must say "link" and not "button". + expect(link.getAttribute('role')).toBeNull() + expect(link.querySelector('svg')?.getAttribute('aria-hidden')).toBe('true') + }) +}) diff --git a/src/components/gymnasts/DeleteGymnastButton.test.tsx b/src/tests/components/gymnasts/DeleteGymnastButton.test.tsx similarity index 95% rename from src/components/gymnasts/DeleteGymnastButton.test.tsx rename to src/tests/components/gymnasts/DeleteGymnastButton.test.tsx index 54e44d3..cb4c828 100644 --- a/src/components/gymnasts/DeleteGymnastButton.test.tsx +++ b/src/tests/components/gymnasts/DeleteGymnastButton.test.tsx @@ -13,7 +13,7 @@ import { } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { createTestClient, failure, ok, type Handler } from '@/tests/apollo' -import DeleteGymnastButton from './DeleteGymnastButton' +import DeleteGymnastButton from '@/components/gymnasts/DeleteGymnastButton' afterEach(cleanup) @@ -113,15 +113,6 @@ describe('DeleteGymnastButton: how it looks', () => { expect(classes).toContain('size-12') // 3rem = 48px expect(classes).not.toContain('size-8') // the smaller size of the button, merged away }) - - it('has a red icon that is bigger than the text beside it, and the red is a theme value (so it works in dark mode)', () => { - renderButton(() => ok({ deleteGymnast: gymnast.id })) - - expect(trigger().className).toContain('text-(--danger)') - expect(trigger().querySelector('svg')?.getAttribute('class')).toContain( - 'size-7', - ) // 28px - }) }) describe('DeleteGymnastButton: how the dialog looks', () => { diff --git a/src/tests/components/gymnasts/DevLoginButton.test.tsx b/src/tests/components/gymnasts/DevLoginButton.test.tsx new file mode 100644 index 0000000..6675057 --- /dev/null +++ b/src/tests/components/gymnasts/DevLoginButton.test.tsx @@ -0,0 +1,134 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ApiError } from '@/lib/apollo/api' +import { devLogin } from '@/lib/apollo/client' +import { isDevLoginEnabled } from '@/lib/devLogin' +import DevLoginButton from '@/components/gymnasts/DevLoginButton' + +vi.mock('@/lib/apollo/client', () => ({ devLogin: vi.fn() })) + +const logIn = vi.mocked(devLogin) +const BUTTON = 'Log in as test coach' +const EMAIL = 'emma@example.com' +const coach = { id: 'c1', name: 'emma', email: EMAIL } + +afterEach(() => { + cleanup() + vi.resetAllMocks() + vi.unstubAllEnvs() +}) + +function typeEmail(address: string) { + fireEvent.change(screen.getByLabelText('Your e-mail address'), { + target: { value: address }, + }) +} + +describe('isDevLoginEnabled', () => { + it('is on only when the build was made with VITE_DEV_LOGIN=true', () => { + for (const [value, expected] of [ + ['true', true], + ['', false], + ['false', false], + ['1', false], + ] as const) { + vi.stubEnv('VITE_DEV_LOGIN', value) + expect(isDevLoginEnabled()).toBe(expected) + } + }) +}) + +describe('DevLoginButton', () => { + it('warns that anyone can log in as anyone', () => { + render( Promise.resolve()} />) + + expect( + screen.getByText( + 'Test login: anyone can log in as anyone, so don’t enter real data.', + ), + ).toBeTruthy() + }) + + it('logs in as the address that was typed, then loads what the page shows', async () => { + logIn.mockResolvedValue(coach) + const onLoggedIn = vi.fn().mockResolvedValue(undefined) + render() + + typeEmail(EMAIL) + fireEvent.click(screen.getByRole('button', { name: BUTTON })) + + await vi.waitFor(() => expect(onLoggedIn).toHaveBeenCalledTimes(1)) + expect(logIn).toHaveBeenCalledExactlyOnceWith(EMAIL) + expect(screen.queryByRole('alert')).toBeNull() + }) + + it("starts with no address, so nobody logs in as someone else's coach by default", () => { + render( Promise.resolve()} />) + + expect( + screen.getByLabelText('Your e-mail address').value, + ).toBe('') + }) + + it('is disabled while it logs in, so a double click makes one login', async () => { + let finish!: () => void + logIn.mockReturnValue( + new Promise((done) => { + finish = () => done(coach) + }), + ) + render( Promise.resolve()} />) + + typeEmail(EMAIL) + fireEvent.click(screen.getByRole('button', { name: BUTTON })) + const busy = await screen.findByRole('button', { + name: 'Logging in…', + }) + expect(busy.disabled).toBe(true) + fireEvent.click(busy) + finish() + + await screen.findByRole('button', { name: BUTTON }) + expect(logIn).toHaveBeenCalledTimes(1) + }) + + it("shows the server's answer when it refuses the address, and keeps what was typed", async () => { + logIn.mockRejectedValue( + new ApiError('BAD_USER_INPUT', 'Enter a valid e-mail address.'), + ) + const onLoggedIn = vi.fn() + render() + + typeEmail('emma@x') + fireEvent.click(screen.getByRole('button', { name: BUTTON })) + + expect((await screen.findByRole('alert')).textContent).toBe( + 'Enter a valid e-mail address.', + ) + expect(onLoggedIn).not.toHaveBeenCalled() + expect( + screen.getByLabelText('Your e-mail address').value, + ).toBe('emma@x') + }) + + it('says so when the server has the development login turned off', async () => { + logIn.mockRejectedValue( + new ApiError('NOT_IMPLEMENTED', 'devLogin is not available yet.'), + ) + const onLoggedIn = vi.fn() + render() + + typeEmail(EMAIL) + fireEvent.click(screen.getByRole('button', { name: BUTTON })) + + expect((await screen.findByRole('alert')).textContent).toBe( + 'This is not available yet.', + ) + expect(onLoggedIn).not.toHaveBeenCalled() + expect( + screen.getByRole('button', { name: BUTTON }).disabled, + ).toBe(false) + }) +}) diff --git a/src/tests/components/gymnasts/GymnastList.test.tsx b/src/tests/components/gymnasts/GymnastList.test.tsx new file mode 100644 index 0000000..8f3b97a --- /dev/null +++ b/src/tests/components/gymnasts/GymnastList.test.tsx @@ -0,0 +1,72 @@ +import { ApolloProvider } from '@apollo/client/react' +import { renderToString } from 'react-dom/server' +import { MemoryRouter } from 'react-router-dom' +import { describe, expect, it } from 'vitest' +import { createTestClient } from '@/tests/apollo' +import GymnastList from '@/components/gymnasts/GymnastList' + +// Each row has a delete button, which uses the client for its mutation. +const { client } = createTestClient(() => new Promise(() => {})) +const renderList = (gymnasts: { id: string; name: string }[]) => + renderToString( + + + {}} + onFailed={() => {}} + /> + + , + ) + +describe('GymnastList', () => { + it('renders names with initials', () => { + const markup = renderList([ + { id: 'one', name: 'Ingrid Haugen' }, + { id: 'two', name: 'Emma Berg' }, + ]) + + expect(markup).toContain('Ingrid Haugen') + expect(markup).toContain('IH') + expect(markup).toContain('Emma Berg') + expect(markup).toContain('EB') + }) + + it('links the name of each gymnast to the page of that gymnast, inside the heading of the card', () => { + const markup = renderList([ + { id: 'one', name: 'Ingrid Haugen' }, + { id: 'two', name: 'Emma Berg' }, + ]) + + expect(markup).toMatch( + /]*>]*href="\/gymnasts\/one"[^>]*>Ingrid Haugen<\/a><\/h2>/, + ) + expect(markup).toMatch( + /]*>]*href="\/gymnasts\/two"[^>]*>Emma Berg<\/a><\/h2>/, + ) + }) + + it('has a heading for the list', () => { + const markup = renderList([]) + + expect(markup).toMatch(/]*>Gymnast list<\/h2>/) + }) + + it('announces the empty list state', () => { + const markup = renderList([]) + + expect(markup).toContain('role="status"') + expect(markup).toContain('You haven’t added any gymnasts yet.') + }) +}) + +describe('GymnastList: the cards show that they can be pressed', () => { + it('has a tint and a deeper shadow on hover, a pointer, and no movement for a visitor who asks for less', () => { + const markup = renderList([{ id: 'one', name: 'Ingrid Haugen' }]) + + expect(markup).toContain('cursor-pointer') + expect(markup).toContain('hover:shadow-[0_6px_16px_rgba(3,2,2,0.22)]') + expect(markup).toContain('motion-reduce:transition-none') + }) +}) diff --git a/src/tests/components/gymnasts/ListSection.test.tsx b/src/tests/components/gymnasts/ListSection.test.tsx new file mode 100644 index 0000000..30caf62 --- /dev/null +++ b/src/tests/components/gymnasts/ListSection.test.tsx @@ -0,0 +1,63 @@ +// @vitest-environment jsdom + +import { cleanup, render, screen, within } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import ListSection from '@/components/gymnasts/ListSection' + +afterEach(cleanup) + +const items = [ + { id: 'a', name: 'First' }, + { id: 'b', name: 'Second' }, +] + +function renderSection(list: typeof items, action?: React.ReactNode) { + render( +
    • {item.name}
    • } + action={action} + />, + ) +} + +describe('ListSection', () => { + it('is a section and a list that are both named by the heading', () => { + renderSection(items) + + const heading = screen.getByRole('heading', { level: 2, name: 'Things' }) + expect(heading.id).toBe('things-heading') + expect(screen.getByRole('region', { name: 'Things' })).toBeTruthy() + const list = screen.getByRole('list', { name: 'Things' }) + expect( + within(list) + .getAllByRole('listitem') + .map((li) => li.textContent), + ).toEqual(['First', 'Second']) + }) + + it('says that there is nothing, and has no list, when there are no items', () => { + renderSection([]) + + expect(screen.getByText('No things yet.')).toBeTruthy() + expect(screen.queryByRole('list')).toBeNull() + }) + + it('shows the action beside the heading, also when the list is empty', () => { + renderSection([], ) + + expect(screen.getByRole('button', { name: 'Add one' })).toBeTruthy() + }) + + it('has a heading that the code can focus but that is not in the tab order', () => { + renderSection(items) + + const heading = screen.getByRole('heading', { level: 2 }) + expect(heading.getAttribute('tabindex')).toBe('-1') + heading.focus() + expect(document.activeElement).toBe(heading) + }) +}) diff --git a/src/tests/components/gymnasts/TrashButton.test.tsx b/src/tests/components/gymnasts/TrashButton.test.tsx new file mode 100644 index 0000000..677bcf4 --- /dev/null +++ b/src/tests/components/gymnasts/TrashButton.test.tsx @@ -0,0 +1,32 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import TrashButton from '@/components/gymnasts/TrashButton' + +afterEach(cleanup) + +describe('TrashButton', () => { + it('is a button named by its label, with a trash icon that is hidden from a screen reader', () => { + render( {}} />) + + const button = screen.getByRole('button', { name: 'Delete Emma Berg' }) + const icon = button.querySelector('svg') + expect(icon).not.toBeNull() + expect(icon?.getAttribute('aria-hidden')).toBe('true') + }) + + it('calls onClick with the event, so the caller can keep the button that was pressed', () => { + let pressed: HTMLElement | null = null + const onClick = vi.fn((event: { currentTarget: HTMLElement }) => { + pressed = event.currentTarget + }) + render() + + const button = screen.getByRole('button') + fireEvent.click(button) + + expect(onClick).toHaveBeenCalledTimes(1) + expect(pressed).toBe(button) + }) +}) diff --git a/src/components/navbar/Navbar.test.tsx b/src/tests/components/navbar/Navbar.test.tsx similarity index 59% rename from src/components/navbar/Navbar.test.tsx rename to src/tests/components/navbar/Navbar.test.tsx index 977c92a..c111fcf 100644 --- a/src/components/navbar/Navbar.test.tsx +++ b/src/tests/components/navbar/Navbar.test.tsx @@ -1,15 +1,15 @@ // @vitest-environment jsdom -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; -import { BrowserRouter, useLocation } from 'react-router-dom'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { AuthContext, type AuthState } from '@/components/auth/useAuth'; -import { logout } from '@/lib/apollo/client'; -import { Navbar } from './Navbar'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { BrowserRouter, useLocation } from 'react-router-dom' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { AuthContext, type AuthState } from '@/components/auth/useAuth' +import { logout } from '@/lib/apollo/client' +import { Navbar } from '@/components/navbar/Navbar' // The real functions would call the server. The Navbar only has to call logout. vi.mock('@/lib/apollo/client', () => ({ logout: vi.fn(async () => undefined), -})); +})) const states: Record = { loading: { status: 'loading', coach: null }, @@ -18,198 +18,203 @@ const states: Record = { status: 'authenticated', coach: { id: 'c1', name: 'Anna', email: 'anna@example.com' }, }, -}; +} /** Shows the address, so a test can see where the navigation went. */ function Address() { - const location = useLocation(); - return {location.pathname}; + const location = useLocation() + return {location.pathname} } beforeEach(() => { - window.history.replaceState({}, '', '/'); -}); + window.history.replaceState({}, '', '/') +}) afterEach(() => { - window.history.replaceState({}, '', '/'); -}); + window.history.replaceState({}, '', '/') +}) const renderNavbar = ({ auth = 'anonymous', isDarkMode = false, onToggleDarkMode = vi.fn(), }: { - auth?: keyof typeof states; - isDarkMode?: boolean; - onToggleDarkMode?: () => void; + auth?: keyof typeof states + isDarkMode?: boolean + onToggleDarkMode?: () => void } = {}) => render( - +
      , - ); + ) describe('Navbar', () => { it('shows the brand and public navigation links', () => { - renderNavbar(); - - const brand = screen.getByRole('link', { name: /wag cop planner home/i }); - expect(brand).toBeTruthy(); - expect(brand.className).toContain('justify-self-start'); - expect(brand.className).toContain('text-left'); - expect(brand.className).toContain('min-w-0'); - const header = screen.getByRole('banner'); - expect(header.className).toContain('px-[clamp(0.5rem,2vw,2.5rem)]'); + renderNavbar() + + const brand = screen.getByRole('link', { name: /wag cop planner home/i }) + expect(brand).toBeTruthy() + expect(brand.className).toContain('justify-self-start') + expect(brand.className).toContain('text-left') + expect(brand.className).toContain('min-w-0') + const header = screen.getByRole('banner') + expect(header.className).toContain('px-[clamp(0.5rem,2vw,2.5rem)]') expect(header.className).toContain( 'min-[720px]:grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)]', - ); - expect(header.className).toContain('relative'); - expect(screen.getByRole('link', { name: 'Search' })).toBeTruthy(); - expect(screen.getByRole('link', { name: 'Calculator' })).toBeTruthy(); - expect(screen.getByRole('link', { name: 'About' })).toBeTruthy(); - expect(screen.queryByRole('link', { name: 'Gymnasts' })).toBeNull(); - }); + ) + expect(header.className).toContain('relative') + expect(screen.getByRole('link', { name: 'Search' })).toBeTruthy() + expect(screen.getByRole('link', { name: 'Calculator' })).toBeTruthy() + expect(screen.getByRole('link', { name: 'About' })).toBeTruthy() + expect(screen.queryByRole('link', { name: 'Gymnasts' })).toBeNull() + }) it('shows the Gymnasts link only when logged in', () => { - renderNavbar({ auth: 'authenticated' }); + renderNavbar({ auth: 'authenticated' }) - expect(screen.getByRole('link', { name: 'Gymnasts' })).toBeTruthy(); - }); + expect(screen.getByRole('link', { name: 'Gymnasts' })).toBeTruthy() + }) it('opens and closes the mobile navigation and closes after a link is selected', () => { - const { container } = renderNavbar(); - - const menuButton = screen.getByRole('button', { name: 'Open menu' }); - expect(menuButton.getAttribute('aria-expanded')).toBe('false'); - expect(menuButton.getAttribute('aria-controls')).toBe('main-menu'); - expect(container.querySelector('#main-menu')?.className).toContain('hidden'); - - fireEvent.click(menuButton); - expect(screen.getByRole('button', { name: 'Close menu' }).getAttribute('aria-expanded')).toBe( - 'true', - ); - expect(screen.getByRole('navigation', { name: 'Primary navigation' }).className).toContain( - 'flex', - ); - - fireEvent.click(screen.getByRole('link', { name: 'About' })); - expect(screen.getByRole('button', { name: 'Open menu' }).getAttribute('aria-expanded')).toBe( - 'false', - ); - expect(container.querySelector('#main-menu')?.className).toContain('hidden'); - }); + const { container } = renderNavbar() + + const menuButton = screen.getByRole('button', { name: 'Open menu' }) + expect(menuButton.getAttribute('aria-expanded')).toBe('false') + expect(menuButton.getAttribute('aria-controls')).toBe('main-menu') + expect(container.querySelector('#main-menu')?.className).toContain('hidden') + + fireEvent.click(menuButton) + expect( + screen + .getByRole('button', { name: 'Close menu' }) + .getAttribute('aria-expanded'), + ).toBe('true') + expect( + screen.getByRole('navigation', { name: 'Primary navigation' }).className, + ).toContain('flex') + + fireEvent.click(screen.getByRole('link', { name: 'About' })) + expect( + screen + .getByRole('button', { name: 'Open menu' }) + .getAttribute('aria-expanded'), + ).toBe('false') + expect(container.querySelector('#main-menu')?.className).toContain('hidden') + }) it('keeps the navigation visible at 720px and hides the menu button', () => { - renderNavbar(); + renderNavbar() - expect(screen.getByRole('navigation', { hidden: true }).className).toContain( - 'min-[720px]:flex', - ); - expect(screen.getByRole('button', { name: 'Open menu' }).className).toContain( - 'min-[720px]:hidden', - ); - }); + expect( + screen.getByRole('navigation', { hidden: true }).className, + ).toContain('min-[720px]:flex') + expect( + screen.getByRole('button', { name: 'Open menu' }).className, + ).toContain('min-[720px]:hidden') + }) it('provides hover underlines and visible keyboard focus styles for navigation links', () => { - renderNavbar(); + renderNavbar() - const navLink = screen.getByRole('link', { name: 'About' }); - expect(navLink.className).toContain("after:content-['']"); - expect(navLink.className).toContain('after:bottom-0'); - expect(navLink.className).toContain('self-start'); - expect(navLink.className).toContain('hover:after:scale-x-100'); - expect(navLink.className).toContain('focus-visible:outline-2'); - expect(navLink.className).toContain('focus-visible:outline-(--text-color)'); - }); + const navLink = screen.getByRole('link', { name: 'About' }) + expect(navLink.className).toContain("after:content-['']") + expect(navLink.className).toContain('after:bottom-0') + expect(navLink.className).toContain('self-start') + expect(navLink.className).toContain('hover:after:scale-x-100') + expect(navLink.className).toContain('focus-visible:outline-2') + expect(navLink.className).toContain('focus-visible:outline-(--text-color)') + }) it('sends the logo and the Search link to the same page, the search page', () => { - renderNavbar(); + renderNavbar() - const logo = screen.getByRole('link', { name: /wag cop planner home/i }); - const search = screen.getByRole('link', { name: 'Search' }); - expect(logo.getAttribute('href')).toBe('/elements'); - expect(search.getAttribute('href')).toBe('/elements'); - }); + const logo = screen.getByRole('link', { name: /wag cop planner home/i }) + const search = screen.getByRole('link', { name: 'Search' }) + expect(logo.getAttribute('href')).toBe('/elements') + expect(search.getAttribute('href')).toBe('/elements') + }) it('keeps the underline visible on the current page link', () => { - window.history.replaceState({}, '', '/elements'); - renderNavbar(); + window.history.replaceState({}, '', '/elements') + renderNavbar() - const currentPageLink = screen.getByRole('link', { name: 'Search' }); - expect(currentPageLink.className).toContain('after:scale-x-100'); - expect(screen.getByRole('navigation').className).not.toContain('pb-[6px]'); - }); + const currentPageLink = screen.getByRole('link', { name: 'Search' }) + expect(currentPageLink.className).toContain('after:scale-x-100') + expect(screen.getByRole('navigation').className).not.toContain('pb-[6px]') + }) it('reports theme toggle clicks to its owner', () => { - const onToggleDarkMode = vi.fn(); - renderNavbar({ onToggleDarkMode }); + const onToggleDarkMode = vi.fn() + renderNavbar({ onToggleDarkMode }) const themeToggle = screen.getByRole('switch', { name: /toggle dark theme/i, - }); - expect(themeToggle.getAttribute('aria-checked')).toBe('false'); + }) + expect(themeToggle.getAttribute('aria-checked')).toBe('false') - fireEvent.click(themeToggle); + fireEvent.click(themeToggle) - expect(onToggleDarkMode).toHaveBeenCalledOnce(); - }); + expect(onToggleDarkMode).toHaveBeenCalledOnce() + }) it('reflects the controlled dark-mode state', () => { - renderNavbar({ isDarkMode: true }); + renderNavbar({ isDarkMode: true }) const themeToggle = screen.getByRole('switch', { name: /toggle dark theme/i, - }); - expect(themeToggle.getAttribute('aria-checked')).toBe('true'); - expect(themeToggle.querySelector('span')?.style.transform).toBe('translateX(22px)'); - }); + }) + expect(themeToggle.getAttribute('aria-checked')).toBe('true') + expect(themeToggle.querySelector('span')?.style.transform).toBe( + 'translateX(22px)', + ) + }) it('keeps the theme toggle thumb at its starting point in light mode', () => { - renderNavbar({ isDarkMode: false }); + renderNavbar({ isDarkMode: false }) const themeToggle = screen.getByRole('switch', { name: /toggle dark theme/i, - }); - expect(themeToggle.querySelector('span')?.style.transform).toBe('translateX(0)'); - }); + }) + expect(themeToggle.querySelector('span')?.style.transform).toBe( + 'translateX(0)', + ) + }) it('has a Log in link to the login page when nobody is logged in', () => { - renderNavbar({ auth: 'anonymous' }); + renderNavbar({ auth: 'anonymous' }) - expect(screen.getByRole('link', { name: 'Log in' }).getAttribute('href')).toBe( - '/login', - ); - expect(screen.queryByRole('button', { name: 'Log out' })).toBeNull(); - }); + expect( + screen.getByRole('link', { name: 'Log in' }).getAttribute('href'), + ).toBe('/login') + expect(screen.queryByRole('button', { name: 'Log out' })).toBeNull() + }) it('has a Log out button, and no Log in, when a coach is logged in', () => { - renderNavbar({ auth: 'authenticated' }); + renderNavbar({ auth: 'authenticated' }) - expect(screen.getByRole('button', { name: 'Log out' })).toBeTruthy(); - expect(screen.queryByRole('link', { name: 'Log in' })).toBeNull(); - }); + expect(screen.getByRole('button', { name: 'Log out' })).toBeTruthy() + expect(screen.queryByRole('link', { name: 'Log in' })).toBeNull() + }) it('says neither Log in nor Log out until the server has answered', () => { - renderNavbar({ auth: 'loading' }); + renderNavbar({ auth: 'loading' }) - expect(screen.queryByRole('link', { name: 'Log in' })).toBeNull(); - expect(screen.queryByRole('button', { name: 'Log out' })).toBeNull(); - expect(screen.queryByRole('link', { name: 'Gymnasts' })).toBeNull(); - }); + expect(screen.queryByRole('link', { name: 'Log in' })).toBeNull() + expect(screen.queryByRole('button', { name: 'Log out' })).toBeNull() + expect(screen.queryByRole('link', { name: 'Gymnasts' })).toBeNull() + }) it('logs out and leaves the page that is for coaches', async () => { - window.history.replaceState({}, '', '/gymnasts'); - renderNavbar({ auth: 'authenticated' }); + window.history.replaceState({}, '', '/gymnasts') + renderNavbar({ auth: 'authenticated' }) - fireEvent.click(screen.getByRole('button', { name: 'Log out' })); + fireEvent.click(screen.getByRole('button', { name: 'Log out' })) - await waitFor(() => expect(logout).toHaveBeenCalledOnce()); - expect(screen.getByTestId('address').textContent).toBe('/elements'); - }); -}); + await waitFor(() => expect(logout).toHaveBeenCalledOnce()) + expect(screen.getByTestId('address').textContent).toBe('/elements') + }) +}) diff --git a/src/tests/components/skipLink/SkipLink.test.tsx b/src/tests/components/skipLink/SkipLink.test.tsx new file mode 100644 index 0000000..7604ad3 --- /dev/null +++ b/src/tests/components/skipLink/SkipLink.test.tsx @@ -0,0 +1,37 @@ +// @vitest-environment jsdom +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { SkipLink } from '@/components/skipLink/SkipLink' + +describe('SkipLink', () => { + it('links to the main content using the default id', () => { + render() + + expect( + screen + .getByRole('link', { name: 'Skip to main content' }) + .getAttribute('href'), + ).toBe('#main-content') + }) + + it('supports a custom main content id', () => { + render() + + expect( + screen + .getByRole('link', { name: 'Skip to main content' }) + .getAttribute('href'), + ).toBe('#page-content') + }) + + it('uses Tailwind classes to hide the link until keyboard focus', () => { + render() + + const link = screen.getByRole('link', { name: 'Skip to main content' }) + expect(link.className).toContain('sr-only') + expect(link.className).toContain('px-md') + expect(link.className).toContain('focus:not-sr-only') + expect(link.className).toContain('focus:top-0') + expect(link.className).toContain('focus:left-0') + }) +}) diff --git a/src/components/ui/DifficultyBadge.test.tsx b/src/tests/components/ui/DifficultyBadge.test.tsx similarity index 95% rename from src/components/ui/DifficultyBadge.test.tsx rename to src/tests/components/ui/DifficultyBadge.test.tsx index 978bf15..8b3afe6 100644 --- a/src/components/ui/DifficultyBadge.test.tsx +++ b/src/tests/components/ui/DifficultyBadge.test.tsx @@ -1,6 +1,6 @@ import { renderToString } from 'react-dom/server' import { describe, expect, it } from 'vitest' -import DifficultyBadge from './DifficultyBadge' +import DifficultyBadge from '@/components/ui/DifficultyBadge' /** The classes of the circle (the first span with rounded-full in the badge). */ const circle = (markup: string) => diff --git a/src/components/ui/ElementVariants.test.tsx b/src/tests/components/ui/ElementVariants.test.tsx similarity index 95% rename from src/components/ui/ElementVariants.test.tsx rename to src/tests/components/ui/ElementVariants.test.tsx index 17565aa..59e21c8 100644 --- a/src/components/ui/ElementVariants.test.tsx +++ b/src/tests/components/ui/ElementVariants.test.tsx @@ -1,7 +1,7 @@ import { renderToString } from 'react-dom/server' import { MemoryRouter } from 'react-router-dom' import { describe, expect, it } from 'vitest' -import ElementVariants from './ElementVariants' +import ElementVariants from '@/components/ui/ElementVariants' describe('ElementVariants', () => { it('renders navigable variant cards with their values', () => { diff --git a/src/components/ui/button.test.tsx b/src/tests/components/ui/button.test.tsx similarity index 57% rename from src/components/ui/button.test.tsx rename to src/tests/components/ui/button.test.tsx index ca49705..96ef7e1 100644 --- a/src/components/ui/button.test.tsx +++ b/src/tests/components/ui/button.test.tsx @@ -19,9 +19,18 @@ describe('Button from shadcn/ui', () => { expect(html).toContain(' { - const html = renderToString() + it('shows the icon of the icon prop before the label, hidden from a screen reader', () => { + const html = renderToString() - expect(html).toContain('border-border') + expect(html.indexOf(' { + const html = renderToString() + + expect(html).not.toContain(' { + const found = APPARATUS.find((a) => a.id === id)! + return { __typename: 'Apparatus', ...found } +} + +/** What the server says to `CatalogStructure`: the four apparatus. */ +export const structureAnswer = () => + ok({ + apparatus: APPARATUS.map((a) => ({ + __typename: 'Apparatus', + ...a, + difficultyValues: [], + groups: [], + })), + }) + +/** An element of the gymnast's list (an Element, with `value`). */ +export function elementItem( + id: string, + code: string, + apparatusId: string, + fields: { + name?: string | null + description?: string + value?: number + removed?: boolean + } = {}, +) { + return { + __typename: 'Element', + id, + code, + name: fields.name ?? null, + description: fields.description ?? `Description of ${code}`, + removed: fields.removed ?? false, + value: fields.value ?? 0.3, + elementGroup: { + __typename: 'ElementGroup', + id: `${apparatusId}-1`, + apparatus: apparatus(apparatusId), + }, + } +} + +/** A vault of the gymnast's list (a Vault, with `dValue`). */ +export function vaultItem( + id: string, + code: string, + fields: { description?: string; dValue?: number } = {}, +) { + return { + __typename: 'Vault', + id, + code, + name: null, + description: fields.description ?? `Description of ${code}`, + removed: false, + dValue: fields.dValue ?? 5.8, + elementGroup: { + __typename: 'ElementGroup', + id: 'VT-1', + apparatus: apparatus('VT'), + }, + } +} + +/** A routine with `count` items (only their ids and whether they are removed are asked for). */ +export function routine( + id: string, + name: string, + apparatusId: string, + dScore: number | null, + fields: { count?: number; updatedAt?: string; removedItem?: boolean } = {}, +) { + const count = fields.count ?? 8 + return { + __typename: 'Routine', + id, + name, + dScore, + updatedAt: fields.updatedAt ?? '2026-09-27T12:00:00.000Z', + apparatus: apparatus(apparatusId), + items: Array.from({ length: count }, (_, index) => ({ + __typename: 'Element', + id: `${id}-item-${index + 1}`, + removed: fields.removedItem === true && index === 0, + })), + } +} + +export type ServerGymnast = ReturnType + +/** + * Ingrid Nilsen, as in the mockup: three routines on uneven bars (3.4, 3.0, 2.6), one on + * balance beam (2.8), nothing on floor and vault; elements on uneven bars and balance beam. + */ +export function exampleGymnast() { + return { + __typename: 'Gymnast', + id: 'gymnast-1', + name: 'Ingrid Nilsen', + routines: [ + routine('r-1', 'Competition 1', 'UB', 3.0, { + count: 10, + updatedAt: '2026-09-20T12:00:00.000Z', + }), + routine('r-2', 'Upgrade with Ricna', 'UB', 3.4, { count: 9 }), + routine('r-3', 'Safe set', 'UB', 2.6, { + count: 8, + updatedAt: '2026-09-01T12:00:00.000Z', + }), + routine('r-4', 'Beam set', 'BB', 2.8, { count: 7 }), + ], + elements: [ + elementItem('UB-1.302-a', '1.302', 'UB', { + description: 'Glide kip, cast to handstand with 1/2 turn', + value: 0.3, + }), + elementItem('UB-4.504-a', '4.504', 'UB', { + name: 'Ricna', + description: 'Stalder to handstand', + value: 0.5, + }), + elementItem('BB-5.205-a', '5.205', 'BB', { + description: 'Clear hip circle backward to handstand', + value: 0.2, + }), + ], + } +} + +/** The answer of `GymnastDetail` for a gymnast. */ +export const gymnastAnswer = (gymnast: unknown = exampleGymnast()) => + ok({ gymnast }) diff --git a/src/lib/apollo/api.test.ts b/src/tests/lib/apollo/api.test.ts similarity index 98% rename from src/lib/apollo/api.test.ts rename to src/tests/lib/apollo/api.test.ts index 991e87d..e787f0e 100644 --- a/src/lib/apollo/api.test.ts +++ b/src/tests/lib/apollo/api.test.ts @@ -4,7 +4,7 @@ import { REQUEST_TIMEOUT_MS, devLoginRequest, refreshRequest, -} from './api' +} from '@/lib/apollo/api' const answer = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status }) diff --git a/src/lib/apollo/authLink.test.ts b/src/tests/lib/apollo/authLink.test.ts similarity index 96% rename from src/lib/apollo/authLink.test.ts rename to src/tests/lib/apollo/authLink.test.ts index 276871a..23b13ff 100644 --- a/src/lib/apollo/authLink.test.ts +++ b/src/tests/lib/apollo/authLink.test.ts @@ -2,9 +2,9 @@ import { ApolloLink } from '@apollo/client/link' import { gql } from '@apollo/client' import { firstValueFrom, of } from 'rxjs' import { type Mock, describe, expect, it, vi } from 'vitest' -import type { AuthPayload } from './api' -import { createAuthLink } from './authLink' -import { createSession, type TokenStorage } from './session' +import type { AuthPayload } from '@/lib/apollo/api' +import { createAuthLink } from '@/lib/apollo/authLink' +import { createSession, type TokenStorage } from '@/lib/apollo/session' const ME = gql` query Me { @@ -128,7 +128,7 @@ describe('the authorization link', () => { }) it('passes on the error, and forgets the login, when the refresh token is refused', async () => { - const { ApiError } = await import('./api') + const { ApiError } = await import('@/lib/apollo/api') const refreshRequest = vi .fn<(refreshToken: string) => Promise>() .mockRejectedValue(new ApiError('UNAUTHENTICATED', 'Log in again.')) diff --git a/src/lib/apollo/cache.test.ts b/src/tests/lib/apollo/cache.test.ts similarity index 99% rename from src/lib/apollo/cache.test.ts rename to src/tests/lib/apollo/cache.test.ts index b979b79..553267f 100644 --- a/src/lib/apollo/cache.test.ts +++ b/src/tests/lib/apollo/cache.test.ts @@ -1,6 +1,6 @@ import { gql } from '@apollo/client' import { describe, expect, it } from 'vitest' -import { createCache } from './cache' +import { createCache } from '@/lib/apollo/cache' const SEARCH = gql` query Search( diff --git a/src/lib/apollo/client.test.ts b/src/tests/lib/apollo/client.test.ts similarity index 96% rename from src/lib/apollo/client.test.ts rename to src/tests/lib/apollo/client.test.ts index 3397190..ca46d29 100644 --- a/src/lib/apollo/client.test.ts +++ b/src/tests/lib/apollo/client.test.ts @@ -3,7 +3,7 @@ // this is the test that the real ones reset the store. import { afterEach, describe, expect, it, vi } from 'vitest' -import { apolloClient, devLogin, logout, session } from './client' +import { apolloClient, devLogin, logout, session } from '@/lib/apollo/client' const payload = { accessToken: 'access-1', diff --git a/src/lib/apollo/errors.test.ts b/src/tests/lib/apollo/errors.test.ts similarity index 96% rename from src/lib/apollo/errors.test.ts rename to src/tests/lib/apollo/errors.test.ts index 8f234af..e8332e3 100644 --- a/src/lib/apollo/errors.test.ts +++ b/src/tests/lib/apollo/errors.test.ts @@ -1,7 +1,7 @@ import { CombinedGraphQLErrors } from '@apollo/client/errors' import { describe, expect, it } from 'vitest' -import { ApiError } from './api' -import { describeError } from './errors' +import { ApiError } from '@/lib/apollo/api' +import { describeError } from '@/lib/apollo/errors' const fromServer = (code: string, message: string) => new CombinedGraphQLErrors({ diff --git a/src/lib/apollo/session.test.ts b/src/tests/lib/apollo/session.test.ts similarity index 98% rename from src/lib/apollo/session.test.ts rename to src/tests/lib/apollo/session.test.ts index 4b75b84..cd77d40 100644 --- a/src/lib/apollo/session.test.ts +++ b/src/tests/lib/apollo/session.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it, vi } from 'vitest' -import { ApiError, type AuthPayload } from './api' -import { REFRESH_TOKEN_KEY, createSession, type TokenStorage } from './session' +import { ApiError, type AuthPayload } from '@/lib/apollo/api' +import { + REFRESH_TOKEN_KEY, + createSession, + type TokenStorage, +} from '@/lib/apollo/session' const payload = (n: number): AuthPayload => ({ accessToken: `access-${n}`, diff --git a/src/lib/catalog.test.ts b/src/tests/lib/catalog.test.ts similarity index 90% rename from src/lib/catalog.test.ts rename to src/tests/lib/catalog.test.ts index ef24f8e..1067161 100644 --- a/src/lib/catalog.test.ts +++ b/src/tests/lib/catalog.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from 'vitest' -import { displayCode, toElementDetail } from './catalog' -import type { CatalogItemResult } from './catalogOperations' +import { displayCode, toElementDetail } from '@/lib/catalog' +import type { CatalogItemResult } from '@/lib/catalogOperations' const shared = { name: null, - imageUrl: 'http://localhost:3001/images/UB/UB-2.305-a.webp', + imageUrl: 'http://localhost:4000/images/UB/UB-2.305-a.webp', removed: false, elementGroup: { id: 'UB-2', diff --git a/src/tests/lib/gymnastDetail.test.ts b/src/tests/lib/gymnastDetail.test.ts new file mode 100644 index 0000000..14726d4 --- /dev/null +++ b/src/tests/lib/gymnastDetail.test.ts @@ -0,0 +1,200 @@ +// The logic behind the Gymnast page: how the routines and the elements of a gymnast are +// grouped by apparatus, and what "highest" means. No React and no network. + +import { describe, expect, it } from 'vitest' +import { + addFromSearchHref, + buildGymnastView, + countText, + defaultApparatusId, + formatUpdated, + newRoutineHref, +} from '@/lib/gymnastDetail' +import { + APPARATUS, + elementItem, + exampleGymnast, + routine, + vaultItem, +} from '@/tests/gymnastDetail' + +const apparatusList = APPARATUS.map((a) => ({ id: a.id, name: a.name })) + +const viewOf = (gymnast: ReturnType) => + buildGymnastView(gymnast as never, apparatusList) + +const byId = (view: ReturnType, id: string) => + view.apparatus.find((a) => a.id === id)! + +describe('buildGymnastView', () => { + it('has all four apparatus, in the order of the structure, even without data', () => { + const view = viewOf({ ...exampleGymnast(), routines: [], elements: [] }) + + expect(view.apparatus.map((a) => a.id)).toEqual(['VT', 'UB', 'BB', 'FX']) + expect(view.apparatus.every((a) => a.highest === null)).toBe(true) + }) + + it('groups routines and elements by apparatus', () => { + const view = viewOf(exampleGymnast()) + + expect(byId(view, 'UB').routines).toHaveLength(3) + expect(byId(view, 'UB').elements).toHaveLength(2) + expect(byId(view, 'BB').routines).toHaveLength(1) + expect(byId(view, 'BB').elements).toHaveLength(1) + expect(byId(view, 'FX').routines).toHaveLength(0) + expect(byId(view, 'VT').elements).toHaveLength(0) + }) + + it('counts the routines and the elements of the whole gymnast', () => { + const view = viewOf(exampleGymnast()) + + expect(view.name).toBe('Ingrid Nilsen') + expect(view.routineCount).toBe(4) + expect(view.elementCount).toBe(3) + }) + + it('gives the highest score of each apparatus, and null when there is none', () => { + const view = viewOf(exampleGymnast()) + + expect(byId(view, 'UB').highest).toBe(3.4) + expect(byId(view, 'BB').highest).toBe(2.8) + expect(byId(view, 'FX').highest).toBeNull() + }) + + it('sorts the routines by score, highest first, and by name when equal', () => { + const gymnast = exampleGymnast() + gymnast.routines = [ + routine('a', 'Zebra', 'UB', 3.0), + routine('b', 'Alpha', 'UB', 3.0), + routine('c', 'Top', 'UB', 3.4), + ] + const names = byId(viewOf(gymnast), 'UB').routines.map((r) => r.name) + + expect(names).toEqual(['Top', 'Alpha', 'Zebra']) + }) + + it('does not count a routine without a score (a removed element), and puts it last', () => { + const gymnast = exampleGymnast() + gymnast.routines = [ + routine('a', 'Broken', 'UB', null, { removedItem: true }), + routine('b', 'Fine', 'UB', 2.0), + ] + const ub = byId(viewOf(gymnast), 'UB') + + expect(ub.highest).toBe(2.0) + expect(ub.routines.map((r) => r.name)).toEqual(['Fine', 'Broken']) + expect(ub.routines[1].score).toBeNull() + expect(ub.routines[1].hasRemovedItem).toBe(true) + }) + + it('marks the highest routine only when there is more than one with a score', () => { + const view = viewOf(exampleGymnast()) + const ub = byId(view, 'UB').routines + const bb = byId(view, 'BB').routines + + expect(ub.map((r) => r.isHighest)).toEqual([true, false, false]) + expect(bb.map((r) => r.isHighest)).toEqual([false]) + }) + + it('marks every routine that shares the highest score', () => { + const gymnast = exampleGymnast() + gymnast.routines = [ + routine('a', 'One', 'UB', 3.4), + routine('b', 'Two', 'UB', 3.4), + routine('c', 'Three', 'UB', 2.0), + ] + const flags = byId(viewOf(gymnast), 'UB').routines.map((r) => r.isHighest) + + expect(flags).toEqual([true, true, false]) + }) + + it('counts the items and keeps the date of the last change', () => { + const routineView = byId(viewOf(exampleGymnast()), 'UB').routines[0] + + expect(routineView.itemCount).toBe(9) + expect(routineView.updatedAt).toBe('2026-09-27T12:00:00.000Z') + }) + + it('reads the value of an element and the D-value of a vault', () => { + const gymnast = exampleGymnast() + gymnast.elements = [ + elementItem('UB-1.302-a', '1.302', 'UB', { value: 0.3 }), + vaultItem('VT-2.50-a', '2.50', { dValue: 5.8 }), + ] as typeof gymnast.elements + const view = viewOf(gymnast) + + expect(byId(view, 'UB').elements[0]).toMatchObject({ + value: 0.3, + isVault: false, + }) + expect(byId(view, 'VT').elements[0]).toMatchObject({ + value: 5.8, + isVault: true, + }) + }) + + it('keeps removed elements and says so', () => { + const gymnast = exampleGymnast() + gymnast.elements = [ + elementItem('UB-9.999-a', '9.999', 'UB', { removed: true }), + ] + + expect(byId(viewOf(gymnast), 'UB').elements[0].removed).toBe(true) + }) +}) + +describe('defaultApparatusId', () => { + it('is the first apparatus that has a routine', () => { + expect(defaultApparatusId(viewOf(exampleGymnast()))).toBe('UB') + }) + + it('is the first apparatus with an element when there is no routine', () => { + const gymnast = { ...exampleGymnast(), routines: [] } + + expect(defaultApparatusId(viewOf(gymnast))).toBe('UB') + }) + + it('is the first apparatus when the gymnast has nothing', () => { + const gymnast = { ...exampleGymnast(), routines: [], elements: [] } + + expect(defaultApparatusId(viewOf(gymnast))).toBe('VT') + }) +}) + +describe('formatUpdated', () => { + it('writes the day and the short month', () => { + expect(formatUpdated('2026-09-27T12:00:00.000Z')).toBe('27 Sep') + }) + + it('gives an empty text for a date that is not valid', () => { + expect(formatUpdated('not a date')).toBe('') + }) +}) + +describe('countText', () => { + it('uses the singular for one and the plural for the rest', () => { + expect(countText(1, 'routine')).toBe('1 routine') + expect(countText(0, 'routine')).toBe('0 routines') + expect(countText(8, 'element')).toBe('8 elements') + }) +}) + +describe('newRoutineHref', () => { + it('goes to the calculator with the gymnast in the address', () => { + expect(newRoutineHref('gymnast-1')).toBe('/calculator?gymnast=gymnast-1') + }) + + it('escapes an id that has characters of its own in an address', () => { + expect(newRoutineHref('a&b=c')).toBe('/calculator?gymnast=a%26b%3Dc') + }) +}) + +describe('addFromSearchHref', () => { + it('goes to the search of the elements with the gymnast in the address', () => { + expect(addFromSearchHref('gymnast-1')).toBe('/elements?gymnast=gymnast-1') + }) + + it('escapes an id that has characters of its own in an address', () => { + expect(addFromSearchHref('a&b=c')).toBe('/elements?gymnast=a%26b%3Dc') + }) +}) diff --git a/src/tests/lib/gymnasts.test.ts b/src/tests/lib/gymnasts.test.ts new file mode 100644 index 0000000..df44e5f --- /dev/null +++ b/src/tests/lib/gymnasts.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +import { + getGymnastInitials, + getGymnastNameError, + limitGymnastNameInput, + normalizeGymnastName, +} from '@/lib/gymnasts' + +describe('gymnast helpers', () => { + it('creates initials from the first and last name', () => { + expect(getGymnastInitials('Ingrid Haugen')).toBe('IH') + }) + + it('handles a single name and empty input', () => { + expect(getGymnastInitials('Madonna')).toBe('MA') + expect(getGymnastInitials(' ')).toBe('?') + }) + + it('trims and collapses whitespace in names', () => { + expect(normalizeGymnastName(' Emma Berg ')).toBe('Emma Berg') + }) + + it('rejects blank and overlong names', () => { + expect(getGymnastNameError(' ')).toBe('Name is required') + expect(getGymnastNameError('A'.repeat(71))).toBe( + 'Names must be 70 characters or fewer.', + ) + expect(getGymnastNameError('Sofia Hansen')).toBeUndefined() + }) + + it('asks for a name when there is none, and takes any name of 1 to 70 characters, like the API', () => { + expect(getGymnastNameError('')).toBe('Name is required') + expect(getGymnastNameError(' \t ')).toBe('Name is required') + expect(getGymnastNameError('A')).toBeUndefined() + expect(getGymnastNameError('Al')).toBeUndefined() + expect(getGymnastNameError('😀')).toBeUndefined() + expect(getGymnastNameError('A'.repeat(70))).toBeUndefined() + }) + + it('cuts what is typed to 70 characters, and counts an emoji as one', () => { + expect(limitGymnastNameInput('A'.repeat(80))).toBe('A'.repeat(70)) + expect(limitGymnastNameInput('A'.repeat(70))).toBe('A'.repeat(70)) + expect(limitGymnastNameInput('Emma')).toBe('Emma') + expect(limitGymnastNameInput('😀'.repeat(80))).toBe('😀'.repeat(70)) + }) +}) diff --git a/src/lib/insecureConnection.test.ts b/src/tests/lib/insecureConnection.test.ts similarity index 92% rename from src/lib/insecureConnection.test.ts rename to src/tests/lib/insecureConnection.test.ts index 25db9cc..8ed6bdd 100644 --- a/src/lib/insecureConnection.test.ts +++ b/src/tests/lib/insecureConnection.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { isInsecureConnection } from './insecureConnection' +import { isInsecureConnection } from '@/lib/insecureConnection' describe('isInsecureConnection', () => { it.each([ diff --git a/src/lib/searchOperations.test.ts b/src/tests/lib/searchOperations.test.ts similarity index 98% rename from src/lib/searchOperations.test.ts rename to src/tests/lib/searchOperations.test.ts index c83da47..8524a9e 100644 --- a/src/lib/searchOperations.test.ts +++ b/src/tests/lib/searchOperations.test.ts @@ -4,7 +4,7 @@ import { MAX_COUNTED, toListRow, type SearchNode, -} from './searchOperations' +} from '@/lib/searchOperations' const group = { name: 'Mounts', diff --git a/src/tests/pages/AboutPage.test.tsx b/src/tests/pages/AboutPage.test.tsx new file mode 100644 index 0000000..3ddac85 --- /dev/null +++ b/src/tests/pages/AboutPage.test.tsx @@ -0,0 +1,114 @@ +// @vitest-environment jsdom +// The About page is static text. These tests check its structure and accessibility, and the +// parts that must not be lost in a rewrite of the wording: the login text and the limits of +// the calculator. + +import { cleanup, render, screen, within } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import { afterEach, describe, expect, it } from 'vitest' +import AboutPage from '@/pages/AboutPage' + +afterEach(cleanup) + +const renderPage = () => + render( + + + , + ) + +/** The section with this heading. */ +const section = (name: string) => + screen + .getByRole('heading', { level: 2, name }) + .closest('section') as HTMLElement + +describe('AboutPage: the page', () => { + it('has a title (WCAG 2.4.2) and one h1', () => { + renderPage() + + expect(document.title).toBe('About – WAG CoP Planner') + expect(screen.getAllByRole('heading', { level: 1 })).toHaveLength(1) + expect(screen.getByRole('heading', { level: 1 }).textContent).toBe('About') + }) + + it('has one main landmark and the headings in order: h1, then h2 only', () => { + renderPage() + + expect(screen.getAllByRole('main')).toHaveLength(1) + const levels = screen + .getAllByRole('heading') + .map((heading) => Number(heading.tagName[1])) + expect(levels[0]).toBe(1) + expect(levels.slice(1).every((level) => level === 2)).toBe(true) + expect(levels.length).toBe(5) + }) + + it('has every section named by its heading, so a screen reader can list them', () => { + renderPage() + + for (const heading of screen.getAllByRole('heading', { level: 2 })) { + const region = heading.closest('section')! + expect(region.getAttribute('aria-labelledby')).toBe(heading.id) + } + }) +}) + +describe('AboutPage: trainers and the login', () => { + it('says that the site is made for trainers, and why there is a login', () => { + renderPage() + + const trainers = section('For trainers') + expect(trainers.textContent).toMatch(/log in/i) + expect(trainers.textContent).toMatch(/gymnast/i) + expect(trainers.textContent).toMatch(/routines/i) + }) + + it('is honest that the login is a test login that anyone can use as anyone', () => { + renderPage() + + expect(section('For trainers').textContent).toMatch( + /anyone can log in as any coach/i, + ) + }) + + it('has a link to the login page', () => { + renderPage() + + const link = within(section('For trainers')).getByRole('link', { + name: /^log in$/i, + }) + expect(link.getAttribute('href')).toBe('/login') + }) +}) + +describe('AboutPage: the calculator and the links', () => { + it('says what the calculator counts and what not, so nobody takes the number for an official score', () => { + renderPage() + + const text = section('For trainers').textContent ?? '' + expect(text).toMatch(/eight highest/i) + expect(text).toMatch(/composition requirements/i) + expect(text).toMatch(/connection values/i) + expect(text).toMatch(/not an official score/i) + }) + + it('has links to the search and the calculator', () => { + renderPage() + + expect( + screen + .getByRole('link', { name: /search the elements/i }) + .getAttribute('href'), + ).toBe('/elements') + expect( + screen.getByRole('link', { name: /calculator/i }).getAttribute('href'), + ).toBe('/calculator') + }) + + it('has no buttons: it is text with links', () => { + renderPage() + + expect(screen.queryAllByRole('button')).toHaveLength(0) + }) +}) diff --git a/src/components/elements/ElementDetailPage.test.tsx b/src/tests/pages/ElementDetailPage.test.tsx similarity index 89% rename from src/components/elements/ElementDetailPage.test.tsx rename to src/tests/pages/ElementDetailPage.test.tsx index f2c48ca..7432d55 100644 --- a/src/components/elements/ElementDetailPage.test.tsx +++ b/src/tests/pages/ElementDetailPage.test.tsx @@ -21,7 +21,8 @@ import { type Handler, type Operation, } from '@/tests/apollo' -import ElementDetailPage from './ElementDetailPage' +import { AuthProvider } from '@/components/auth/AuthProvider' +import ElementDetailPage from '@/pages/ElementDetailPage' const variant = (id: string) => ({ __typename: 'Element', @@ -77,15 +78,23 @@ const vaultAnswer = () => }, }) +/** The page in the providers of the app. Nobody is logged in (`Me` answers null). */ function renderPage(id: string, handler: Handler) { - const { client } = createTestClient(handler) + const { client } = createTestClient((operation) => + operation.operationName === 'Me' ? ok({ me: null }) : handler(operation), + ) render( - - - } /> - - + + + + } + /> + + + , ) } @@ -145,6 +154,10 @@ describe('ElementDetailPage', () => { ).toBe('/elements/UB-2.305-b') expect(screen.queryByText(/no longer in the Code of Points/)).toBeNull() expect(button('Try in a routine').disabled).toBe(false) + // Nobody is logged in: "Add to gymnast" is a link to the login, with a lock, not a disabled button. + const add = await screen.findByRole('link', { name: /^Add to gymnast/ }) + expect(add.getAttribute('href')).toBe('/login') + expect(add.querySelector('svg.lucide-lock-keyhole')).not.toBeNull() }) it('shows the name of the group for an element', async () => { diff --git a/src/components/elements/ElementsPage.test.tsx b/src/tests/pages/ElementsPage.test.tsx similarity index 99% rename from src/components/elements/ElementsPage.test.tsx rename to src/tests/pages/ElementsPage.test.tsx index 15ee94f..c75b6df 100644 --- a/src/components/elements/ElementsPage.test.tsx +++ b/src/tests/pages/ElementsPage.test.tsx @@ -32,7 +32,7 @@ import { type Operation, } from '@/tests/apollo' import { exampleElementRows, exampleTotalCount } from '@/tests/elementRows' -import ElementsPage from './ElementsPage' +import ElementsPage from '@/pages/ElementsPage' afterEach(cleanup) diff --git a/src/tests/pages/GymnastPage.test.tsx b/src/tests/pages/GymnastPage.test.tsx new file mode 100644 index 0000000..c8e5ab8 --- /dev/null +++ b/src/tests/pages/GymnastPage.test.tsx @@ -0,0 +1,508 @@ +// @vitest-environment jsdom +// The Gymnast page runs on the real Apollo Client, with a stub for `fetch` (see +// src/tests/apollo.ts). The stub answers like the server does. + +import { ApolloProvider } from '@apollo/client/react' +import { + cleanup, + fireEvent, + render, + screen, + waitFor, + within, +} from '@testing-library/react' +import { MemoryRouter, Route, Routes } from 'react-router-dom' +import { afterEach, describe, expect, it } from 'vitest' +import { AuthProvider } from '@/components/auth/AuthProvider' +import GymnastPage from '@/pages/GymnastPage' +import { + createTestClient, + deferred, + failure, + ok, + type Answer, + type Handler, +} from '@/tests/apollo' +import { + exampleGymnast, + gymnastAnswer, + structureAnswer, + type ServerGymnast, +} from '@/tests/gymnastDetail' + +/** + * The page inside the same providers as in the app. `Me` (who is logged in) and the + * structure are answered here; `detail` answers `GymnastDetail`, `others` the rest. + */ +function renderPage( + detail: () => Answer | Promise = () => gymnastAnswer(), + others: Handler = () => failure('INTERNAL_SERVER_ERROR', 'unexpected'), + address = '/gymnasts/gymnast-1', +) { + const handler: Handler = (operation) => { + switch (operation.operationName) { + case 'Me': + return ok({ + me: { + __typename: 'Coach', + id: 'coach-1', + name: 'Anna', + email: 'anna@example.com', + }, + }) + case 'CatalogStructure': + return structureAnswer() + case 'GymnastDetail': + return detail() + default: + return others(operation) + } + } + const { client, calls } = createTestClient(handler) + render( + + + + + } /> + + + + , + ) + return { + calls, + removed: () => + calls.filter((c) => c.operationName === 'RemoveGymnastElement'), + } +} + +const tab = (name: RegExp | string) => screen.getByRole('tab', { name }) +const panel = () => screen.getByRole('tabpanel') +const routinesList = () => + within(panel()).getByRole('list', { name: 'Routines' }) +const elementsList = () => + within(panel()).getByRole('list', { name: 'Element list' }) + +afterEach(cleanup) + +describe('GymnastPage: loading', () => { + it('says that it is loading, and has a title for it (WCAG 2.4.2)', async () => { + const answer = deferred() + renderPage(() => answer.promise) + + expect(screen.getByRole('status').textContent).toContain('Loading') + expect(document.title).toBe('Loading gymnast – WAG CoP Planner') + answer.resolve(gymnastAnswer()) + expect( + await screen.findByRole('heading', { level: 1, name: 'Ingrid Nilsen' }), + ).toBeTruthy() + }) + + it('asks the server once for the gymnast and once for the apparatus', async () => { + const page = renderPage() + await screen.findByRole('heading', { level: 1 }) + + const names = page.calls.map((c) => c.operationName).sort() + expect(names.filter((n) => n === 'GymnastDetail')).toHaveLength(1) + expect(names.filter((n) => n === 'CatalogStructure')).toHaveLength(1) + expect( + page.calls.find((c) => c.operationName === 'GymnastDetail')?.variables, + ).toEqual({ + id: 'gymnast-1', + }) + }) +}) + +describe('GymnastPage: the header', () => { + it('has the name as the one h1 and the name in the title', async () => { + renderPage() + + const heading = await screen.findByRole('heading', { level: 1 }) + expect(heading.textContent).toBe('Ingrid Nilsen') + expect(screen.getAllByRole('heading', { level: 1 })).toHaveLength(1) + expect(document.title).toBe('Ingrid Nilsen – WAG CoP Planner') + }) + + it('has a description for search engines (the page is checked by Lighthouse)', async () => { + renderPage() + await screen.findByRole('heading', { level: 1 }) + + const meta = document.head.querySelector('meta[name="description"]') + expect(meta?.getAttribute('content')).toMatch(/routines/) + }) + + it('has a link back to all gymnasts', async () => { + renderPage() + + const link = await screen.findByRole('link', { name: 'All gymnasts' }) + expect(link.getAttribute('href')).toBe('/gymnasts') + }) +}) + +describe('GymnastPage: the apparatus', () => { + it('has a tab for each apparatus with only its name', async () => { + renderPage() + await screen.findByRole('heading', { level: 1 }) + + const tabs = within( + screen.getByRole('tablist', { name: 'Apparatus' }), + ).getAllByRole('tab') + expect(tabs.map((t) => t.textContent?.replace(/\s+/g, ' ').trim())).toEqual( + ['Vault', 'Uneven bars', 'Balance beam', 'Floor exercise'], + ) + }) + + it('opens the first apparatus that has a routine', async () => { + renderPage() + await screen.findByRole('heading', { level: 1 }) + + expect(tab(/^Uneven bars/).getAttribute('aria-selected')).toBe('true') + expect(tab(/^Vault/).getAttribute('aria-selected')).toBe('false') + expect( + within(panel()) + .getAllByRole('heading', { level: 2 }) + .map((h) => h.textContent), + ).toEqual(['Routines', 'Element list']) + }) + + it('shows the routines and the elements of the chosen apparatus, and changes with the tab', async () => { + renderPage() + await screen.findByRole('heading', { level: 1 }) + + expect(within(routinesList()).getAllByRole('listitem')).toHaveLength(3) + expect(within(elementsList()).getAllByRole('listitem')).toHaveLength(2) + + fireEvent.click(tab(/^Balance beam/)) + + await waitFor(() => + expect(within(routinesList()).getAllByRole('listitem')).toHaveLength(1), + ) + expect(within(routinesList()).getByText('Beam set')).toBeTruthy() + expect(within(elementsList()).getAllByRole('listitem')).toHaveLength(1) + }) + + it('can be used with the arrow keys, Home and End', async () => { + renderPage() + await screen.findByRole('heading', { level: 1 }) + + const ub = tab(/^Uneven bars/) + ub.focus() + fireEvent.keyDown(ub, { key: 'ArrowRight' }) + // The tab takes the focus, and its panel is shown with it (the tabs follow the focus). + await waitFor(() => + expect(document.activeElement).toBe(tab(/^Balance beam/)), + ) + await waitFor(() => + expect(tab(/^Balance beam/).getAttribute('aria-selected')).toBe('true'), + ) + + fireEvent.keyDown(document.activeElement!, { key: 'End' }) + await waitFor(() => + expect(document.activeElement).toBe(tab(/^Floor exercise/)), + ) + fireEvent.keyDown(document.activeElement!, { key: 'Home' }) + await waitFor(() => expect(document.activeElement).toBe(tab(/^Vault/))) + }) + + it('says that there is nothing yet, for an apparatus with no routines and no elements', async () => { + renderPage() + await screen.findByRole('heading', { level: 1 }) + + fireEvent.click(tab(/^Floor exercise/)) + + expect( + await screen.findByText('No routines on Floor exercise yet.'), + ).toBeTruthy() + expect( + screen.getByText('No elements on Floor exercise in the list yet.'), + ).toBeTruthy() + }) +}) + +describe('GymnastPage: the routines', () => { + it('shows the name, the size, the date of the last change and the score', async () => { + renderPage() + await screen.findByRole('heading', { level: 1 }) + + const rows = within(routinesList()).getAllByRole('listitem') + expect(rows[0].textContent).toContain('Upgrade with Ricna') + expect(rows[0].textContent).toContain('9 elements · Updated 27 Sep') + expect(rows[0].textContent).toContain('3.4') + expect(rows[1].textContent).toContain('Competition 1') + expect(rows[2].textContent).toContain('Safe set') + }) + + it('marks the highest routine with text, and only that one', async () => { + renderPage() + await screen.findByRole('heading', { level: 1 }) + + const rows = within(routinesList()).getAllByRole('listitem') + expect(within(rows[0]).getByText('Highest')).toBeTruthy() + expect(within(rows[1]).queryByText('Highest')).toBeNull() + expect(within(rows[2]).queryByText('Highest')).toBeNull() + }) + + it('reads the score with a word before it, for a screen reader', async () => { + renderPage() + await screen.findByRole('heading', { level: 1 }) + + const first = within(routinesList()).getAllByRole('listitem')[0] + expect(first.textContent).toContain('D-score 3.4') + }) + + it('says that a routine without a score has an element that is gone, and does not show 0', async () => { + const gymnast: ServerGymnast = exampleGymnast() + gymnast.routines = [ + { + ...gymnast.routines[0], + dScore: null, + items: [{ ...gymnast.routines[0].items[0], removed: true }], + }, + ] + renderPage(() => gymnastAnswer(gymnast)) + await screen.findByRole('heading', { level: 1 }) + + const row = within(routinesList()).getAllByRole('listitem')[0] + expect(row.textContent).toMatch(/no d-score/i) + expect(row.textContent).toMatch(/no longer in the Code of Points/i) + expect(row.textContent).not.toContain('0.0') + }) +}) + +describe('GymnastPage: the list of elements', () => { + it('shows the code, the name or the description, the difficulty and a link to the element', async () => { + renderPage() + await screen.findByRole('heading', { level: 1 }) + + const rows = within(elementsList()).getAllByRole('listitem') + const first = within(rows[0]).getByRole('link', { + name: /^1\.302 Glide kip, cast to handstand with 1\/2 turn/, + }) + expect(first.getAttribute('href')).toBe('/elements/UB-1.302-a') + expect(rows[0].textContent).toContain('Difficulty C') + // An element with a name is shown by its name. + expect( + within(rows[1]).getByRole('link', { name: /^4\.504 Ricna/ }), + ).toBeTruthy() + }) + + it('warns about an element that is no longer in the Code of Points', async () => { + const gymnast = exampleGymnast() + gymnast.elements[0] = { ...gymnast.elements[0], removed: true } + renderPage(() => gymnastAnswer(gymnast)) + await screen.findByRole('heading', { level: 1 }) + + const first = within(elementsList()).getAllByRole('listitem')[0] + expect(first.textContent).toMatch(/no longer in the Code of Points/i) + }) +}) + +describe('GymnastPage: removing an element', () => { + const removeAnswer = (gymnast: ServerGymnast, itemId: string): Answer => + ok({ + removeGymnastElement: { + ...gymnast, + elements: gymnast.elements.filter((e) => e.id !== itemId), + }, + }) + + const removeButton = (name: RegExp | string) => + screen.getByRole('button', { name }) + + it('has a button for each element that says which element it removes', async () => { + renderPage() + await screen.findByRole('heading', { level: 1 }) + + expect(removeButton(/^Remove 1\.302 Glide kip/)).toBeTruthy() + expect(removeButton(/^Remove 4\.504 Ricna/)).toBeTruthy() + }) + + it('asks before it removes, and changes nothing when the answer is no', async () => { + const page = renderPage() + await screen.findByRole('heading', { level: 1 }) + + fireEvent.click(removeButton(/^Remove 1\.302/)) + const dialog = await screen.findByRole('alertdialog') + expect(dialog.textContent).toContain( + '1.302 will be removed from Ingrid Nilsen', + ) + expect(page.removed()).toHaveLength(0) + + fireEvent.click(screen.getByRole('button', { name: 'No, keep it' })) + await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull()) + expect(page.removed()).toHaveLength(0) + expect(within(elementsList()).getAllByRole('listitem')).toHaveLength(2) + }) + + it('gives the focus back to the button that was pressed when the answer is no', async () => { + renderPage() + await screen.findByRole('heading', { level: 1 }) + const button = removeButton(/^Remove 1.302/) + + fireEvent.click(button) + await screen.findByRole('alertdialog') + fireEvent.click(screen.getByRole('button', { name: 'No, keep it' })) + + await waitFor(() => expect(document.activeElement).toBe(button)) + }) + + it('removes the element, shows the new list, says so and moves the focus to the heading of the list', async () => { + const gymnast = exampleGymnast() + const page = renderPage( + () => gymnastAnswer(gymnast), + () => removeAnswer(gymnast, 'UB-1.302-a'), + ) + await screen.findByRole('heading', { level: 1 }) + + fireEvent.click(removeButton(/^Remove 1\.302/)) + await screen.findByRole('alertdialog') + fireEvent.click(screen.getByRole('button', { name: 'Yes, remove' })) + + await waitFor(() => + expect(within(elementsList()).getAllByRole('listitem')).toHaveLength(1), + ) + expect(page.removed()[0].variables).toEqual({ + gymnastId: 'gymnast-1', + itemId: 'UB-1.302-a', + }) + expect(screen.getByRole('status').textContent).toContain( + '1.302 removed from the list.', + ) + expect(screen.queryByRole('alertdialog')).toBeNull() + await waitFor(() => + expect(document.activeElement).toBe( + screen.getByRole('heading', { level: 2, name: 'Element list' }), + ), + ) + }) + + it('sends one request even if it is confirmed twice quickly', async () => { + const gymnast = exampleGymnast() + const answer = deferred() + const page = renderPage( + () => gymnastAnswer(gymnast), + () => answer.promise, + ) + await screen.findByRole('heading', { level: 1 }) + fireEvent.click(removeButton(/^Remove 1\.302/)) + await screen.findByRole('alertdialog') + + const confirm = screen.getByRole('button', { name: 'Yes, remove' }) + fireEvent.click(confirm) + fireEvent.click(confirm) + + await waitFor(() => expect(page.removed()).toHaveLength(1)) + answer.resolve(removeAnswer(gymnast, 'UB-1.302-a')) + await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull()) + expect(page.removed()).toHaveLength(1) + }) + + it('keeps the element and tells why when the server says no', async () => { + renderPage( + () => gymnastAnswer(), + () => failure('INTERNAL_SERVER_ERROR', 'boom'), + ) + await screen.findByRole('heading', { level: 1 }) + + fireEvent.click(removeButton(/^Remove 1\.302/)) + await screen.findByRole('alertdialog') + fireEvent.click(screen.getByRole('button', { name: 'Yes, remove' })) + + const alert = await screen.findByRole('alert') + expect(alert.textContent).toContain('Something went wrong on our side') + expect(within(elementsList()).getAllByRole('listitem')).toHaveLength(2) + await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull()) + }) +}) + +describe('GymnastPage: when it cannot show the gymnast', () => { + it('says that the gymnast was not found, with a link to the gymnasts', async () => { + renderPage(() => failure('NOT_FOUND', 'No such gymnast')) + + expect( + await screen.findByRole('heading', { + level: 1, + name: 'Gymnast not found', + }), + ).toBeTruthy() + expect(screen.getByRole('alert').textContent).toContain( + 'No gymnast of yours', + ) + expect( + screen + .getByRole('link', { name: 'Back to your gymnasts' }) + .getAttribute('href'), + ).toBe('/gymnasts') + expect(document.title).toBe('Gymnast not found – WAG CoP Planner') + }) + + it('says that there is no gymnast for a gymnast that is null', async () => { + renderPage(() => ok({ gymnast: null })) + + expect( + await screen.findByRole('heading', { + level: 1, + name: 'Gymnast not found', + }), + ).toBeTruthy() + }) + + it('shows the error and a retry button for an error that can be tried again', async () => { + let attempt = 0 + renderPage(() => { + attempt += 1 + if (attempt === 1) throw new TypeError('Failed to fetch') + return gymnastAnswer() + }) + + expect((await screen.findByRole('alert')).textContent).toContain( + 'Could not reach the server', + ) + expect(document.title).toBe('Gymnast unavailable – WAG CoP Planner') + + fireEvent.click(screen.getByRole('button', { name: 'Try again' })) + expect( + await screen.findByRole('heading', { level: 1, name: 'Ingrid Nilsen' }), + ).toBeTruthy() + }) +}) + +describe('GymnastPage: structure for a screen reader', () => { + it('has the headings in order: h1, then h2 for the routines and the list, and no level skipped', async () => { + renderPage() + await screen.findByRole('heading', { level: 1 }) + + const levels = screen + .getAllByRole('heading') + .map((h) => Number(h.tagName[1])) + expect(levels).toEqual([1, 2, 2]) + }) + + it('has one main landmark', async () => { + renderPage() + await screen.findByRole('heading', { level: 1 }) + + expect(screen.getAllByRole('main')).toHaveLength(1) + }) +}) + +describe('GymnastPage: New routine and Add from search', () => { + it('has a "New routine" button that goes to the calculator with the gymnast in the address', async () => { + renderPage() + + const link = await screen.findByRole('link', { name: 'New routine' }) + expect(link.getAttribute('href')).toBe('/calculator?gymnast=gymnast-1') + // A plus before the label, hidden from a screen reader: the name is still "New routine". + const plus = link.querySelector('svg.lucide-plus') + expect(plus?.getAttribute('aria-hidden')).toBe('true') + }) + + it('has an "Add from search" button that goes to the search with the gymnast in the address', async () => { + renderPage() + + const link = await screen.findByRole('link', { name: 'Add from search' }) + expect(link.getAttribute('href')).toBe('/elements?gymnast=gymnast-1') + expect(link.querySelector('svg.lucide-plus')).not.toBeNull() + }) +}) diff --git a/src/components/auth/LoginPage.test.tsx b/src/tests/pages/LoginPage.test.tsx similarity index 99% rename from src/components/auth/LoginPage.test.tsx rename to src/tests/pages/LoginPage.test.tsx index 31d07da..eb066ae 100644 --- a/src/components/auth/LoginPage.test.tsx +++ b/src/tests/pages/LoginPage.test.tsx @@ -12,7 +12,7 @@ import { AuthContext, type AuthState } from '@/components/auth/useAuth' import { devLogin } from '@/lib/apollo/client' import { ApiError } from '@/lib/apollo/api' import { DEMO_EMAIL } from '../../../shared/demo' -import LoginPage from './LoginPage' +import LoginPage from '@/pages/LoginPage' // The real function would call the server. The page only has to call devLogin. vi.mock('@/lib/apollo/client', () => ({ devLogin: vi.fn() })) diff --git a/src/components/gymnasts/YourGymnastsPage.test.tsx b/src/tests/pages/YourGymnastsPage.test.tsx similarity index 99% rename from src/components/gymnasts/YourGymnastsPage.test.tsx rename to src/tests/pages/YourGymnastsPage.test.tsx index 76f0ef7..ff19938 100644 --- a/src/components/gymnasts/YourGymnastsPage.test.tsx +++ b/src/tests/pages/YourGymnastsPage.test.tsx @@ -21,7 +21,7 @@ import { type Answer, type Handler, } from '@/tests/apollo' -import YourGymnastsPage from './YourGymnastsPage' +import YourGymnastsPage from '@/pages/YourGymnastsPage' /** What the server says to `YourGymnasts` for a coach with these gymnasts. */ const coachWith = (names: string[]) => diff --git a/src/tests/setupTests.ts b/src/tests/setupTests.ts index d8935b0..17cdac2 100644 --- a/src/tests/setupTests.ts +++ b/src/tests/setupTests.ts @@ -1,7 +1,7 @@ -import '@testing-library/jest-dom'; -import { cleanup } from '@testing-library/react'; -import { afterEach } from 'vitest'; +import '@testing-library/jest-dom' +import { cleanup } from '@testing-library/react' +import { afterEach } from 'vitest' afterEach(() => { - cleanup(); -}); \ No newline at end of file + cleanup() +}) diff --git a/src/styles/gymnastTheme.test.ts b/src/tests/styles/gymnastTheme.test.ts similarity index 87% rename from src/styles/gymnastTheme.test.ts rename to src/tests/styles/gymnastTheme.test.ts index c0ad01e..f6a729e 100644 --- a/src/styles/gymnastTheme.test.ts +++ b/src/tests/styles/gymnastTheme.test.ts @@ -14,7 +14,7 @@ const here = (path: string) => fileURLToPath(new URL(path, import.meta.url)) const read = (path: string) => readFileSync(here(path), 'utf8').replace(/\r\n/g, '\n') -const css = read('./variables.css') +const css = read('../../styles/variables.css') const light = css.slice( css.indexOf(':root'), css.indexOf("[data-theme='dark']"), @@ -203,12 +203,17 @@ describe('the components of the gymnasts and login pages', () => { '--accent-color', ] - const files = ['../components/gymnasts/', '../components/auth/'].flatMap( - (dir) => + // The two pages (src/pages/) and the components they use. + const files = [ + '../../pages/YourGymnastsPage.tsx', + '../../pages/GymnastPage.tsx', + '../../pages/LoginPage.tsx', + ...['../../components/gymnasts/', '../../components/auth/'].flatMap((dir) => readdirSync(here(dir)) .filter((name) => name.endsWith('.tsx') && !name.endsWith('.test.tsx')) .map((name) => dir + name), - ) + ), + ] it.each(files)( '%s: a button with the outline variant also sets its colors for dark mode', @@ -231,6 +236,7 @@ describe('the components of the gymnasts and login pages', () => { it('looks at the components of both pages and not at their tests', () => { expect(files.some((f) => f.endsWith('GymnastRow.tsx'))).toBe(true) + expect(files.some((f) => f.endsWith('YourGymnastsPage.tsx'))).toBe(true) expect(files.some((f) => f.endsWith('LoginPage.tsx'))).toBe(true) expect(files.some((f) => f.endsWith('.test.tsx'))).toBe(false) }) @@ -242,40 +248,16 @@ describe('the components of the gymnasts and login pages', () => { }) }) -/** The color that `color-mix(in srgb, a, b N%)` gives, as a hex color. */ -const mix = (a: string, b: string, percent: number) => { - const channel = (hex: string, i: number) => - parseInt(hex.slice(1 + i * 2, 3 + i * 2), 16) - const part = (i: number) => - Math.round( - channel(a, i) * (1 - percent / 100) + channel(b, i) * (percent / 100), - ) - return ( - '#' + [0, 1, 2].map((i) => part(i).toString(16).padStart(2, '0')).join('') - ) -} - -describe('the card of a gymnast on hover', () => { +describe('the "Highest" label of a routine', () => { it.each(themes)( - 'still has a name that can be read on the tinted card in the %s theme (at least 4.5:1)', + 'can be read in the colors of the buttons in the %s theme (at least 4.5:1)', (_name, block) => { - const hovered = mix( - valueOf(block, '--surface'), - valueOf(block, '--text-color'), - 8, - ) expect( - contrast(valueOf(block, '--text-color'), hovered), + contrast( + valueOf(block, '--button-text'), + valueOf(block, '--button-bg'), + ), ).toBeGreaterThanOrEqual(4.5) }, ) - - it.each(themes)( - 'is different from the card at rest in the %s theme, so the visitor can see it', - (_name, block) => { - const rest = valueOf(block, '--surface') - const hovered = mix(rest, valueOf(block, '--text-color'), 8) - expect(hovered).not.toBe(rest) - }, - ) }) diff --git a/tsconfig.json b/tsconfig.json index 36109fc..57b1335 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,12 +12,9 @@ } ], "compilerOptions": { - "paths": { "@/*": ["./src/*"] }, "types": ["jest", "@testing-library/jest-dom"] - - } } diff --git a/vite.config.ts b/vite.config.ts index 4510e4e..dc55397 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -12,12 +12,17 @@ export default defineConfig({ tailwindcss(), ], server: { - // In development the browser calls the backend through Vite, so the app - // and the API have the same address and CORS is not needed. The backend - // listens on port 3001 (npm run backend). + // Keep requests under the app's base path in development and production. + // Vite strips the prefix before forwarding to the backend. proxy: { - '/graphql': 'http://localhost:3001', - '/images': 'http://localhost:3001', + '/project2/graphql': { + target: 'http://localhost:4000', + rewrite: (path) => path.replace(/^\/project2/, ''), + }, + '/project2/images': { + target: 'http://localhost:4000', + rewrite: (path) => path.replace(/^\/project2/, ''), + }, }, }, resolve: { @@ -28,4 +33,4 @@ export default defineConfig({ setupFiles: ['./src/tests/setupTests.ts'], fileParallelism: false, }, -}); +})