+ Loading gymnast +
+ +diff --git a/README.md b/README.md index 1d463c4..417aa2f 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ The calculator and About pages are placeholders. Adding items from the catalog t | [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 @@ -546,13 +547,16 @@ Run `ANALYZE` after adding or removing many rows, or PostgreSQL plans from old s The assignment asks for "a form of automated end-to-end testing" of "a longer sequence of interactions", and for testing of the API. The component tests (Vitest) and the API tests in `backend/src/server/` already exist; the end-to-end tests drive the **whole application in a real browser**, against the real backend and the real database, the way a user does. They are in `e2e/` and use [Playwright](https://playwright.dev), with [axe-core](https://github.com/dequelabs/axe-core-npm/tree/develop/packages/playwright) for the accessibility checks. -| File | What it tests | -| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `e2e/elements-flow.e2e.ts` | One long flow: search, filter by apparatus and group, "Load more", sort, open an element, go back, "Clear filters". It checks the address, the results, the title and the focus at each step. | -| `e2e/vault.e2e.ts` | The vault difficulty bands, and sorting vault by its D-score in both directions. | -| `e2e/atypical.e2e.ts` | Atypical use: an address with filters that do not exist, a search without a result, the 80-character limit, a structure that fails to load (the request is made to fail), the disabled group filter, and keyboard-only use. | -| `e2e/accessibility.e2e.ts` | axe-core on the page in its main states: filtered and sorted, a menu open, the reason for a disabled filter shown, vault, no result, dark mode, a phone (375 px) and reflow at 320 px. A violation fails the test. | -| `e2e/api.e2e.ts` | The GraphQL API over HTTP: search, filter, sort, cursors, the error codes and `Apparatus.difficultyValues`. The same requests can be sent to the virtual machine. | +| File | What it tests | +| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `e2e/elements-flow.e2e.ts` | One long flow: search, filter by apparatus and group, "Load more", sort, open an element, go back, "Clear filters". It checks the address, the results, the title and the focus at each step. | +| `e2e/vault.e2e.ts` | The vault difficulty bands, and sorting vault by its D-score in both directions. | +| `e2e/atypical.e2e.ts` | Atypical use: an address with filters that do not exist, a search without a result, the 80-character limit, a structure that fails to load (the request is made to fail), the disabled group filter, and keyboard-only use. | +| `e2e/accessibility.e2e.ts` | axe-core on the page in its main states: filtered and sorted, a menu open, the reason for a disabled filter shown, vault, no result, dark mode, a phone (375 px) and reflow at 320 px. A violation fails the test. | +| `e2e/api.e2e.ts` | The GraphQL API over HTTP: search, filter, sort, cursors, the error codes and `Apparatus.difficultyValues`. The same requests can be sent to the virtual machine. | +| `e2e/gymnast-page.e2e.ts` | The Gymnast page and "Your gymnasts" logged in as a test coach: axe in light and dark mode, on a phone and with the remove dialog open; no sideways scrolling at 280, 320, 360 and 390 px (also with long names and the text spacing of WCAG 1.4.12); the size of what is pressed on a phone; the tabs with the arrow keys, Home and End; where the focus goes around the remove dialog; and the colors of a row and of the trash icon under the pointer. | +| `e2e/element-page.e2e.ts` | The page of an element and of a vault: axe in light and dark mode and on a phone, and "Add to gymnast" (a link to the login for a visitor, a menu of the gymnasts for a coach, and the element in the list afterwards). | +| `e2e/gymnast-helpers.ts` | Not a test: the login through the API, the test data, axe and the colors as the browser works them out, for the two files above. | ### How to run them @@ -565,7 +569,7 @@ npx playwright install chromium # the browser, once (about 150 MB) npm run e2e ``` -`npm run e2e` starts the backend and the frontend itself (and uses them if they are already running), runs the tests, and prints the result. `npm run e2e:report` opens the report with a screenshot and a trace of every test that failed. The tests only read the catalog, so they can be run again and again. +`npm run e2e` starts the backend and the frontend itself (and uses them if they are already running), runs the tests, and prints the result. `npm run e2e:report` opens the report with a screenshot and a trace of every test that failed. The tests of the catalog only read it. The tests of the gymnast pages log in through the API (`devLogin`) as a test coach of their own (`e2e-gymnast-page@example.test`, `e2e-element-page@example.test`), make gymnasts for it, and delete them at the start and at the end, so all of them can be run again and again. The backend that the tests start has `DEV_LOGIN` turned on for this (`playwright.config.ts`); an address given with `E2E_BASE_URL` must have it too (the virtual machine has, see `docs/VM_SETUP.md`). Settings, all optional, as environment variables: @@ -580,26 +584,27 @@ Settings, all optional, as environment variables: - **Playwright, not Cypress.** It is made for end-to-end tests, drives real Chromium, waits for the page by itself (a test says what it expects, and Playwright retries until it is true), and has an official axe-core integration. The tests are TypeScript like the rest of the project, and the files end in `.e2e.ts` so that Vitest does not pick them up. - **The tests depend on the seeded catalog** (the data in `data/`). They avoid exact numbers where a relation is enough ("fewer than the whole catalog", "every code starts with the number of the group"), but they do assume that uneven bars, floor and vault exist with their groups and difficulty values. +- **A test that has never failed is not known to work.** The tests of the gymnast pages were checked by breaking the code on purpose (the grid column that made a page wider than a phone, the dark hover color, the size of the trash button, the dark background of the element page, the arrow keys of the tabs, and the focus after the dialog) and seeing the right test fail. A new test of this kind should be checked the same way. - **The accessibility tests are automated checks, not a full audit.** axe-core finds what a program can find. What needs a person, such as a screen reader (NVDA, VoiceOver) and whether the order of the focus makes sense, has to be tested by hand. `region` is turned off for a state with a menu open: the menus are in a portal (the Base UI default), which axe reports as best practice, not as a WCAG failure. - **They are not run by a server on every pull request.** The course git server has no runners (see issue #76), so they are run by the person who makes the change, and before each delivery against the virtual machine with `E2E_BASE_URL`. ## Project structure -| Folder | Contents | -| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | -| `backend/` | The backend package: TypeScript, Prisma, the seed script, the catalog search, the GraphQL server and the GraphQL schema | -| `backend/src/` | The backend code, with its tests next to the code they test (`*.test.ts`). See "Where the tests are" below | -| `data/` | The catalog data extracted from the Code of Points: JSON files and one image per element | -| `docs/` | Project plans, data model, API, accessibility, sustainability, AI usage, and catalog extraction documentation | -| `e2e/` | The end-to-end tests (Playwright): whole flows in a real browser, accessibility checks and the API over HTTP. See "End-to-end tests" | -| `public/` | Static files served as they are, such as the favicon | -| `scripts/extract-cop/` | The Python script that extracts `data/` from the Code of Points PDF | -| `src/` | The React frontend, shared UI components, API operations, Apollo Client, and frontend tests | -| `src/pages/` | One file for each route in `src/App.tsx` (the Elements page, the element page, Your gymnasts, Log in and the placeholder pages) | -| `src/components/` | The parts of the pages, in one folder for each part of the site (`elements/`, `gymnasts/`, `auth/`, `navbar/`) | -| `src/components/ui/` | The shadcn/ui components (written by the shadcn/ui CLI) and a few small shared components (`DifficultyBadge`, `ElementValueBadge`, `ElementVariants`) | -| `src/lib/` | The GraphQL operations, Apollo Client and the logic that is not a component | -| `src/tests/` | The frontend tests (Vitest), in the same folders as the code they test, and the shared test helpers. See "Where the tests are" below | +| Folder | Contents | +| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| `backend/` | The backend package: TypeScript, Prisma, the seed script, the catalog search, the GraphQL server and the GraphQL schema | +| `backend/src/` | The backend code, with its tests next to the code they test (`*.test.ts`). See "Where the tests are" below | +| `data/` | The catalog data extracted from the Code of Points: JSON files and one image per element | +| `docs/` | Project plans, data model, API, accessibility, sustainability, AI usage, and catalog extraction documentation | +| `e2e/` | The end-to-end tests (Playwright): whole flows in a real browser, accessibility checks and the API over HTTP. See "End-to-end tests" | +| `public/` | Static files served as they are, such as the favicon | +| `scripts/extract-cop/` | The Python script that extracts `data/` from the Code of Points PDF | +| `src/` | The React frontend, shared UI components, API operations, Apollo Client, and frontend tests | +| `src/pages/` | One file for each route in `src/App.tsx` (the Elements page, the element page, Your gymnasts, Log in and the placeholder pages) | +| `src/components/` | The parts of the pages, in one folder for each part of the site (`elements/`, `gymnasts/`, `auth/`, `navbar/`) | +| `src/components/ui/` | The shadcn/ui components (written by the shadcn/ui CLI) and a few small shared components (`DifficultyBadge`, `ElementVariants`, `LinkButton`) | +| `src/lib/` | The GraphQL operations, Apollo Client and the logic that is not a component | +| `src/tests/` | The frontend tests (Vitest), in the same folders as the code they test, and the shared test helpers. See "Where the tests are" below | ### Where the tests are diff --git a/docs/ACCESSIBILITY.md b/docs/ACCESSIBILITY.md index 33b8ea6..447bc97 100644 --- a/docs/ACCESSIBILITY.md +++ b/docs/ACCESSIBILITY.md @@ -39,6 +39,19 @@ The interface supports light and dark themes with separate color tokens. Interac The color themes have not yet been comprehensively measured against WCAG contrast criteria. Focus, contrast, zoom, and screen-reader behavior need verification in the complete application. +### The Gymnast page and the element page + +These pages were checked for a narrow phone and for both themes, in a real browser (`e2e/gymnast-page.e2e.ts`, `e2e/element-page.e2e.ts`): + +- **Reflow (WCAG 1.4.10) and text spacing (1.4.12):** no sideways scrolling at 280, 320, 360 and 390 px, also with a name of 56 letters without a space and with the text spacing of 1.4.12. +- **Contrast (1.4.3, 1.4.11):** axe finds no violation in light and dark mode, and the dark mode of the element page and the badges of the vaults. The color under the pointer on a row is measured in the browser, and the text on it can be read. +- **Not only color (1.4.1):** the trash icon turns red on hover and while it is pressed, but its meaning is in its name ("Delete ..."), and a touch screen, which cannot hover, gets the red from the pressed state. +- **Keyboard (2.1.1, 2.4.3, 2.4.7):** the arrow keys, Home and End move between the apparatus tabs; the question "are you sure?" closes with Escape and gives the focus back to the button; after a removal the focus goes to the heading of the list; the menu of "Add to gymnast" opens with the keyboard. +- **Size of what is pressed (2.5.8):** every button, tab and link in the main part is at least 40 px tall and wide on a phone (the criterion asks for 24). +- **Names and roles (4.1.2):** a link that looks like a button is still a link (`LinkButton`); "Add to gymnast" for a visitor who is not logged in is a link with a lock and the text "(log in first)", not a disabled button, which the keyboard cannot reach and which does not say why. + +**Where:** [GymnastPage.tsx](../src/pages/GymnastPage.tsx), [ApparatusTabs.tsx](../src/components/gymnasts/ApparatusTabs.tsx), [TrashButton.tsx](../src/components/gymnasts/TrashButton.tsx), [LinkButton.tsx](../src/components/ui/LinkButton.tsx), [AddToGymnast.tsx](../src/components/elements/AddToGymnast.tsx). + ### Base UI The project uses Base UI primitives through the shared button component. This gives buttons a reusable, accessible foundation rather than implementing their basic behavior from scratch. It does not automatically make every composed page or interaction accessible; labels, descriptions, keyboard flow, and visual contrast still need to be designed and checked by the project. @@ -61,4 +74,5 @@ Accessibility requirements should be considered while planning each feature and ## Related documentation - [Sustainable choices](./SUSTAINABILITY.md) – data transfer, image delivery, performance, and maintainability. +- [State management](./STATE_MANAGEMENT.md) – where each kind of state lives, and the shared parts. - [UI plans](./UI_PLANS.md) – target users and user needs. diff --git a/docs/AI_USAGE.md b/docs/AI_USAGE.md index f278aa0..e3dc921 100644 --- a/docs/AI_USAGE.md +++ b/docs/AI_USAGE.md @@ -532,3 +532,45 @@ - **Tool:** Claude Code - **What the team did:** Wrote issue #97: move the pages out of `src/components/`, so it is clear which files are routes and which are parts of pages, and describe the folders in the README. - **What Claude did:** Moved the four pages and their tests with `git mv` (so their history follows them), and moved the three placeholder pages out of `App.tsx` into their own files, so every route has a file in `src/pages/`. Updated the imports, the links in the README and the docs, and the "Project structure" table. Found that `src/styles/gymnastTheme.test.ts` reads the files of the gymnasts and login pages by folder, so after the move it would have stopped checking the two pages without failing; added them to the list. The shared components in `components/ui/` were left where they are (see the issue). After that, asked to move the frontend tests to `src/tests/` as well: moved all 29 with `git mv` into the same folders as the code they test, changed their relative imports to the `@/` alias (so a test does not depend on where it is), and updated the paths that `gymnastTheme.test.ts` reads files from. + +## The Gymnast page (`src/pages/GymnastPage.tsx`, `src/components/gymnasts/`, `src/components/ConfirmDialog.tsx`, `src/components/Error*.tsx`, `src/lib/gymnastDetail*.ts`, and the parts of the element page that grew out of it) + +- **Tool:** Claude Code +- **What the team did:** Wrote issue #149 and the mockup (`docs/design/gymnastDetail.png`), and decided the first scope: the apparatus tabs, the routines and the list of elements of one apparatus, and removing an element from the list. Editing the name and deleting a routine belong to other issues. After the first result the team kept directing the work in several rounds (next bullet), and the scope grew: "New routine" and "Add from search" became links, and the element page got a working "Add to gymnast", the shared button for "Try in a routine", a dark mode and the difficulty badge for vaults. The first plan said that no other page would be changed; that is no longer true, and the pages that were changed are listed under "Simplifications and refactoring". +- **How the design was reached:** The first result was not the final one. The design went through several iterations, and the team asked for changes after each result. Each result was checked by the team on localhost (the dev server, in light and dark mode, and at narrow widths in the browser's inspector) and by reading the code, and what was seen there decided the next request. The requests, in the order they came: + - The tabs: only the name of the apparatus (no highest score), only the line like the shadcn/ui "line" example (no white tile), the line under the chosen tab on the same line as the line below it, and a lower tab. + - No count under the name of the gymnast ("5 routines · 11 elements in the list"). + - The remove button: a trash icon instead of a cross, then one shared component for the gymnasts page and the Gymnast page, in the text color until the pointer is over it or it is pressed. + - The D-score of a routine in a smaller size, and no underline when the pointer is over the name of an element. + - A "New routine" button as in the mockup, the same button component and color as the other buttons, linking to the calculator with the gymnast in the address; then a plus in the button, and an "Add from search" button of the same kind. + - The text of the remove dialog: "1.104 will be removed from [name of the gymnast]". + - The hover color of the lists in dark mode, the color of the "Highest" label (the color of the buttons), and a layout that works on a phone. + - On the element page: "Try in a routine" in the shared button, an "Add to gymnast" that works, dark mode, and the difficulty badge for vaults. + + In some of the rounds the team asked how to decide, and Claude answered with options and a recommendation first (the red of the trash icon on a phone, whether the lists and rows should be one component, one shared error component). The team chose, and Claude changed the code. + +- **What Claude did:** Wrote the plan and the tests first (the grouping logic, the page on the real Apollo Client with a stub for `fetch`, and the confirmation dialog), watched them fail, and then wrote the page. One query asks for the gymnast with all routines and elements (they are limited by the API), and the page groups them by apparatus itself, so changing apparatus costs no request. Reused `PageTitle`, `Button` with `PILL`, `DifficultyBadge`, the structure query of the filters, the Base UI tabs and alert dialog, and the existing color tokens. The tabs are the shadcn/ui tabs (added with the shadcn CLI). In the later rounds Claude made the changes above, one at a time, ran the checks after each round (the lint, the type check, the tests and mostly the build), and wrote tests for the new behavior. "Add to gymnast" is a menu of the gymnasts of the coach when someone is logged in, and for a visitor who is not logged in it is a link to the login page with a lock icon, which sends the visitor back to the element after the login. Claude recommended that over a disabled button (a disabled button cannot be reached by the keyboard and does not say why), and the team has not asked for the other way. +- **Accessibility and phone check (WCAG 2.2 AA):** Done with axe-core and Playwright against the running app, logged in with the test login, on a test gymnast with long names and routines. The widths were 280, 320, 360, 390 and 768 px, in light and dark mode, also with the text spacing of WCAG 1.4.12 added, and with the keyboard on the tabs and the remove dialog. The contrast of the hover colors was calculated from the tokens and not judged by eye. It found: + - At 320 px the page was 3 px wider than the window, because a grid column took the width of its content. Fixed with `minmax(0, 1fr)`. + - At 320 px the name of an element got about 60 px, one word on a line, because the code was beside it. The code is now above the name on a narrow window. + - The four tabs broke into three rows. They are now two by two on a narrow window. + - In dark mode the hover color of a row was 1.05:1 to the page behind the card, so a row under the pointer looked like a hole in the card. It is now a deeper navy of the theme (`--card-hover`): 1.67:1 to the page, 1.29:1 to the card at rest, and 12.4:1 for the text. A test follows the token. + - "Your gymnasts" ran out of the window at every width: the title and the button did not wrap, the list had the same grid problem, and a long name without spaces was not broken. + - The value under the difficulty badge could not be read in dark mode, the focus ring of the icon buttons was missing (the shared button sets `outline-none`), and the focus was lost after removing an element (a disabled button loses the focus, and one `ref` shared by the panels of all apparatus ended up empty). These three were found in the first round and fixed then. + + After the fixes the Gymnast page has no sideways scroll at any of the widths, in both modes, also with a name of about 40 letters without spaces and with the text spacing of 1.4.12, and axe reports no violations. The arrow keys change the tab, the remove dialog fits in a 320 px window, the focus is inside it, and Escape gives the focus back to the button that was pressed. The element page (an element and a vault, in both modes, at 320 and 1100 px) also has no violations, with the color contrast checked, and no sideways scroll. + + One mistake in the check itself is worth noting: the first runs used the same refresh token in several browser contexts, but a refresh token can be used once, so the login ended and some of the runs measured the login page and not the Gymnast page. It was found when a later step could not find the tabs on the page. Every context now logs in for itself, and the run stops if the heading of the page it got is "Log in". + +- **Tests, and what the tests are worth:** The checks above were first run by hand with scripts that were not kept. At the team's request the part that a component test cannot see (jsdom has no layout, no focus rules and no real colors) was written as end-to-end tests in `e2e/gymnast-page.e2e.ts` and `e2e/element-page.e2e.ts` (see "End-to-end tests" in the README): axe in both modes and on a phone, no sideways scrolling at four narrow widths, the size of what is pressed on a phone, the arrow keys on the tabs, where the focus goes around the dialog, the real hover color of a row and of the trash icon, and "Add to gymnast" in a real browser. The team also asked for a list of every test to judge them one by one, and removed the ones that only compared class names or read source files, because they break on a harmless change and stay green when the real thing is broken: the text and icon colors of `ErrorMessage`, the caller's classes on `ErrorPage` and `TrashButton`, the look of `AddButton`, the plus and the placeholder message of "Try in a routine", the classes of the trash button and of the cards, the trash icon and the vault badge by class name, the eight tests that read the source of the element page for light-only colors, the test that read `RoutineList` for the button colors, the tab order test, and a touch test that stayed green when the bug it should catch was added. In a second round the team removed more tests that checked how something is written and not what it does, or that tested the library and not our code: the classes and the icon class names (`lucide-plus`, `lucide-lock-keyhole`, `lucide-trash-2`), a regex over the HTML of `Button`, the tests that parsed `--card-hover` in the CSS and checked a ratio of 1.5:1 that was our own choice (the real hover color is measured in the browser instead), the fifth Tab press in the dialog (that is the behaviour of Base UI, and what is ours, the focus back to the button, is kept), and the repeated checks of the exact dialog text in the browser (a component test has it). The four axe runs of the vault page became one, in dark mode on a phone, which is the hardest case for the badges. The team said that test coverage is measured by someone else, so no coverage tool was added. A better touch test was found (a device that cannot hover must not get the hover color; it passes now and fails when the hover is not gated), but it has not been added to the repository. A green test can be worthless, so each important end-to-end test was checked by breaking the code on purpose and seeing it fail: the grid column that makes a page wider than a phone, the dark hover color, the size of the trash button, the dark background of the element page, the arrow keys of the tabs, and the focus after the dialog. That check found one weak test: removing the grid fix did not fail the sideways-scrolling test, because the routine names of the test data were short enough to break. The test data now has a routine name of 56 letters without a space, and the test fails without the fix. Two other tests first failed because they read the focus and a color that changes over 150 ms too early; they now wait, and the app was not wrong. +- **Simplifications and refactoring:** Claude first listed what was repeated, and then which of it should be shared and which should not. + - Found: the same skeleton of a list (section, heading, empty text, list in a card) in three places; the long hover string of a card in two places; a link that covers a whole card copied three times; two almost equal error states; and eleven places with `role="alert"` that differ in color, markup and button. + - Shared: `ListSection` (the routines and the elements), `StretchedLink`, `AddButton` and `LinkButton` (a link with the look of the pill button; `Button` also takes an `icon` now, which the menu buttons use), `TrashButton`, `ConfirmDialog`, and the constants of `listCard.ts` (also used by the cards of the gymnasts page). Errors are in three layers, `ErrorMessage`, `ErrorPanel` and `ErrorPage`, which the Gymnast page uses. + - `LinkButton` exists because of a finding of the end-to-end run: given a link, the Base UI `Button` warns in the console that it is not a `
+ {error &&+
{element.description}
)} diff --git a/src/components/elements/ElementDiagram.tsx b/src/components/elements/ElementDiagram.tsx index 6e56b6c..f1af41c 100644 --- a/src/components/elements/ElementDiagram.tsx +++ b/src/components/elements/ElementDiagram.tsx @@ -8,7 +8,8 @@ export default function ElementDiagram({ }) { return (+ + No longer in the Code of Points +
+ )} ++ Loading gymnast +
+ +{emptyText}
+ ) : ( ++ + {routine.name} + + {routine.isHighest && ( + + Highest + + )} +
++ {countText(routine.itemCount, 'element')} + Updated{' '} + +
+ {routine.hasRemovedItem && ( ++ + + No D-score: an element in this routine is no longer in the Code of + Points. + +
+ )} ++ {routine.score === null ? ( + + ) : ( + <> + D-score + {routine.score.toFixed(1)} + > + )} +
++
This element has no other variants.
) : (+
{variant.description}
+
{message}
{retryable && ( @@ -51,7 +51,7 @@ export default function ElementDetailPage() { type="button" disabled={retrying !== null} onClick={() => retry(problem)} - className="rounded-md px-3 py-2 text-(--text-color-dark) underline underline-offset-4 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring disabled:no-underline disabled:opacity-70" + className="rounded-md px-3 py-2 text-(--text-color) underline underline-offset-4 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--text-color) disabled:no-underline disabled:opacity-70" > {retrying ? 'Trying again…' : 'Try again'} @@ -65,16 +65,16 @@ export default function ElementDetailPage() { return (Loading element details
+
No catalog item matches this address.
View a catalog element @@ -105,7 +105,7 @@ export default function ElementDetailPage() { return (This element is no longer in the Code of Points.
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+ {removal.announcement} +
+{announcement}
-{JSON.stringify(location.state)}
+} + +function renderButton(options: { + me?: object | null + me_answer?: Promise]*>A removed element/.exec(markup)?.[1]
- expect(reasonId).toBeTruthy()
expect(
- markup.match(new RegExp(`aria-describedby="${reasonId}"`, 'g')),
- ).toHaveLength(2)
+ 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/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(]*>]*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([])
@@ -49,9 +66,6 @@ describe('GymnastList: the cards show that they can be pressed', () => {
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/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(
+