From 9442a9a6d1010c71d99b5a2789a206012937257e Mon Sep 17 00:00:00 2001 From: Ronja Date: Fri, 30 Oct 2026 17:04:19 +0100 Subject: [PATCH 01/13] feat: connect the sort on the Elements page --- src/components/elements/ElementsPage.test.tsx | 206 +++++++++++++++++- src/components/elements/ElementsPage.tsx | 72 ++++-- src/components/elements/SortMenu.tsx | 129 +++++++++++ src/lib/searchOperations.ts | 95 +++++++- 4 files changed, 479 insertions(+), 23 deletions(-) create mode 100644 src/components/elements/SortMenu.tsx diff --git a/src/components/elements/ElementsPage.test.tsx b/src/components/elements/ElementsPage.test.tsx index e23bac5..be16b19 100644 --- a/src/components/elements/ElementsPage.test.tsx +++ b/src/components/elements/ElementsPage.test.tsx @@ -208,7 +208,7 @@ describe('ElementsPage: the page', () => { } }) - it('says so in a status when a filter or the sort is not connected yet, and not before', async () => { + it('says so in a status when a filter is not connected yet, and not before', async () => { renderPage(serverWithRows) await screen.findByText(/Showing/) const status = screen.getByTestId('controls-status') @@ -218,11 +218,209 @@ describe('ElementsPage: the page', () => { expect(status.textContent).toBe( 'The apparatus filter is not connected yet.', ) + }) +}) - fireEvent.click( - screen.getByRole('button', { name: /Sort by Code\W+Ascending/ }), +const sortButton = (name = /Sort by Code\W+Ascending/) => + screen.getByRole('button', { name }) + +/** Opens the menu like a click does: the pointer goes down and up on the button. */ +async function openSortMenu() { + const button = sortButton(/Sort by/) + fireEvent.pointerDown(button, { button: 0, pointerType: 'mouse' }) + fireEvent.mouseDown(button, { button: 0 }) + fireEvent.pointerUp(button, { button: 0, pointerType: 'mouse' }) + fireEvent.mouseUp(button, { button: 0 }) + fireEvent.click(button, { button: 0 }) + return screen.findByRole('menu') +} + +const choose = (name: string) => + fireEvent.click(screen.getByRole('menuitemradio', { name })) + +describe('ElementsPage: the sort', () => { + it('sends no sort when it is the default (code, ascending), and keeps the address clean', async () => { + const { calls } = renderPage(serverWithRows) + await screen.findByText(/Showing/) + + expect(calls[0].variables).not.toHaveProperty('sort') + expect(address()).toBe('/elements') + }) + + it('has the fields and the directions as radio items, and the chosen ones are checked', async () => { + renderPage(serverWithRows) + await screen.findByText(/Showing/) + await openSortMenu() + + const checked = (name: string) => + screen.getByRole('menuitemradio', { name }).getAttribute('aria-checked') + for (const name of [ + 'Code', + 'Difficulty', + 'Name', + 'Ascending', + 'Descending', + ]) + expect(screen.getByRole('menuitemradio', { name })).toBeTruthy() + expect(checked('Code')).toBe('true') + expect(checked('Ascending')).toBe('true') + expect(checked('Name')).toBe('false') + expect(checked('Descending')).toBe('false') + }) + + it('sorts by name: sends it, puts it in the address and says it on the button', async () => { + const { calls } = renderPage(serverWithRows) + await screen.findByText(/Showing/) + await openSortMenu() + choose('Name') + + await waitFor(() => expect(address()).toBe('/elements?sort=name')) + await waitFor(() => + expect(calls.at(-1)!.variables).toMatchObject({ + sort: { field: 'NAME', direction: 'ASC' }, + }), + ) + expect(sortButton(/Sort by Name\W+Ascending/)).toBeTruthy() + }) + + it('sorts descending, and goes back to a clean address with the default again', async () => { + const { calls } = renderPage(serverWithRows) + await screen.findByText(/Showing/) + await openSortMenu() + choose('Descending') + + await waitFor(() => expect(address()).toBe('/elements?dir=desc')) + await waitFor(() => + expect(calls.at(-1)!.variables).toMatchObject({ + sort: { field: 'CODE', direction: 'DESC' }, + }), ) - expect(status.textContent).toBe('Sorting is not connected yet.') + + await openSortMenu() + choose('Ascending') + await waitFor(() => expect(address()).toBe('/elements')) + }) + + it('starts from the sort in the address, and keeps the search when the sort changes', async () => { + const { calls } = renderPage( + serverWithRows, + '/elements?search=amanar&sort=name&dir=desc', + ) + await screen.findByText(/Showing/) + + expect(calls[0].variables).toMatchObject({ + search: 'amanar', + sort: { field: 'NAME', direction: 'DESC' }, + }) + expect(field().value).toBe('amanar') + expect(sortButton(/Sort by Name\W+Descending/)).toBeTruthy() + + await openSortMenu() + choose('Ascending') + await waitFor(() => + expect(address()).toBe('/elements?search=amanar&sort=name'), + ) + }) + + it('keeps the sort in the address when the search changes', async () => { + renderPage(serverWithRows, '/elements?sort=name') + await screen.findByText(/Showing/) + + fireEvent.change(field(), { target: { value: 'biles' } }) + await waitFor( + () => expect(address()).toBe('/elements?sort=name&search=biles'), + wait, + ) + }) + + it('starts a new sort from the first page, and loads more with the same sort', async () => { + const { calls } = renderPage(pagedServer(4)) + await screen.findByText(/Showing 4 of/) + + fireEvent.click(screen.getByRole('button', { name: 'Load more elements' })) + await screen.findByText(/Showing 7 of/) + expect(calls.at(-1)).toMatchObject({ + operationName: 'SearchCatalogMore', + variables: { after: expect.any(String) }, + }) + + await openSortMenu() + choose('Name') + await screen.findByText(/Showing 4 of/) + const last = calls.at(-1)! + expect(last.operationName).toBe('SearchCatalog') + expect(last.variables).not.toHaveProperty('after') + expect(last.variables).toMatchObject({ sort: { field: 'NAME' } }) + + fireEvent.click(screen.getByRole('button', { name: 'Load more elements' })) + await screen.findByText(/Showing 7 of/) + expect(calls.at(-1)!.variables).toMatchObject({ + sort: { field: 'NAME', direction: 'ASC' }, + after: expect.any(String), + }) + }) + + it('cannot sort by difficulty without an apparatus, and says why', async () => { + const { calls } = renderPage(serverWithRows) + await screen.findByText(/Showing/) + await openSortMenu() + + const difficulty = screen.getByRole('menuitemradio', { name: 'Difficulty' }) + expect(difficulty.getAttribute('aria-disabled')).toBe('true') + const reason = screen.getByText( + 'Choose an apparatus to sort by difficulty.', + ) + expect(difficulty.getAttribute('aria-describedby')).toBe(reason.id) + + fireEvent.click(difficulty) + expect(address()).toBe('/elements') + expect(calls).toHaveLength(1) + }) + + it('does not send difficulty without an apparatus, even if the address says so (BAD_USER_INPUT)', async () => { + const { calls } = renderPage( + serverWithRows, + '/elements?sort=difficulty&dir=desc', + ) + await screen.findByText(/Showing/) + + expect(calls[0].variables).toMatchObject({ + sort: { field: 'CODE', direction: 'DESC' }, + }) + expect(sortButton(/Sort by Code\W+Descending/)).toBeTruthy() + }) + + it('can sort by difficulty with an apparatus, and sends the apparatus with it', async () => { + const { calls } = renderPage(serverWithRows, '/elements?apparatus=VT') + await screen.findByText(/Showing/) + await openSortMenu() + + const difficulty = screen.getByRole('menuitemradio', { name: 'Difficulty' }) + expect(difficulty.getAttribute('aria-disabled')).not.toBe('true') + expect(screen.queryByText(/Choose an apparatus/)).toBeNull() + choose('Difficulty') + + await waitFor(() => + expect(address()).toBe('/elements?apparatus=VT&sort=difficulty'), + ) + await waitFor(() => + expect(calls.at(-1)!.variables).toMatchObject({ + filter: { apparatusId: 'VT' }, + sort: { field: 'DIFFICULTY', direction: 'ASC' }, + }), + ) + expect(sortButton(/Sort by Difficulty\W+Ascending/)).toBeTruthy() + }) + + it('ignores a sort in the address that it does not know', async () => { + const { calls } = renderPage( + serverWithRows, + '/elements?sort=banana&dir=sideways', + ) + await screen.findByText(/Showing/) + + expect(calls[0].variables).not.toHaveProperty('sort') + expect(sortButton()).toBeTruthy() }) }) diff --git a/src/components/elements/ElementsPage.tsx b/src/components/elements/ElementsPage.tsx index e84e201..9bd16e7 100644 --- a/src/components/elements/ElementsPage.tsx +++ b/src/components/elements/ElementsPage.tsx @@ -1,18 +1,22 @@ import { useEffect, useRef, useState, type FormEvent } from 'react' import { useQuery } from '@apollo/client/react' import { useSearchParams } from 'react-router-dom' -import { ArrowUpDown, ChevronDown, Plus, Search } from 'lucide-react' +import { ChevronDown, Plus, Search } from 'lucide-react' import ResultsTable from '@/components/elements/ResultsTable' +import SortMenu from '@/components/elements/SortMenu' import { Button } from '@/components/ui/button' import { describeError } from '@/lib/apollo/errors' import { formatTotal, MAX_SEARCH_LENGTH, PAGE_SIZE, + parseSort, SEARCH_CATALOG_MORE_QUERY, SEARCH_CATALOG_QUERY, SEARCH_DELAY_MS, + sortParams, toListRow, + type Sort, } from '@/lib/searchOperations' const PILL = @@ -20,8 +24,8 @@ const PILL = const FILTERS = ['Apparatus', 'Element group', 'Difficulty'] -// TODO(#110): the filters and the sort are only drawn so far. Each one says that it is -// not connected yet, like the buttons on the element page. +// TODO(#110): the filters are only drawn so far. Each one says that it is not connected +// yet, like the buttons on the element page. The sort is connected (#128). export default function ElementsPage() { const [status, setStatus] = useState('') const [searchParams, setSearchParams] = useSearchParams() @@ -34,11 +38,45 @@ export default function ElementsPage() { const [text, setText] = useState(searchParams.get('search') ?? '') const written = useRef(search) + // The sort is in the address too (?sort=difficulty&dir=desc, the default left out). + // Difficulty needs an apparatus; the apparatus filter will be in the address as + // ?apparatus=..., and until it is connected nothing sets it. + const apparatusId = searchParams.get('apparatus') + const sort = parseSort( + searchParams.get('sort'), + searchParams.get('dir'), + Boolean(apparatusId), + ) + + // Writing one part of the address keeps the others. function writeAddress(value: string) { const trimmed = value.trim() if (trimmed === written.current) return written.current = trimmed - setSearchParams(trimmed ? { search: trimmed } : {}, { replace: true }) + setSearchParams( + (params) => { + const next = new URLSearchParams(params) + if (trimmed) next.set('search', trimmed) + else next.delete('search') + return next + }, + { replace: true }, + ) + } + + // A new sort is a new list (the cache keys it), so it starts from the first page. + function changeSort(chosen: Sort) { + setSearchParams( + (params) => { + const next = new URLSearchParams(params) + next.delete('sort') + next.delete('dir') + for (const [key, value] of Object.entries(sortParams(chosen))) + next.set(key, value) + return next + }, + { replace: true }, + ) } useEffect(() => { @@ -56,9 +94,17 @@ export default function ElementsPage() { } }, [search]) + // What the search is sent with. The default sort is not sent, so the first request is + // the same as before the sort was connected. + const searchVariables = { + search: search || null, + ...(apparatusId && { filter: { apparatusId } }), + ...(sort.field !== 'CODE' || sort.direction !== 'ASC' ? { sort } : {}), + } + const { data, previousData, loading, error, refetch, fetchMore } = useQuery( SEARCH_CATALOG_QUERY, - { variables: { search: search || null, first: PAGE_SIZE } }, + { variables: { ...searchVariables, first: PAGE_SIZE } }, ) // While a new search is on its way the old results stay, so the page does not jump. const shown = (data ?? previousData)?.searchCatalog @@ -82,7 +128,7 @@ export default function ElementsPage() { try { await fetchMore({ query: SEARCH_CATALOG_MORE_QUERY, - variables: { search: search || null, first: PAGE_SIZE, after }, + variables: { ...searchVariables, first: PAGE_SIZE, after }, }) } catch (problem) { setMoreError({ search, message: describeError(problem).message }) @@ -144,16 +190,12 @@ export default function ElementsPage() { ))} - + />

void + className?: string +}) { + const reasonId = useId() + const blocked = (needsApparatus?: boolean) => + Boolean(needsApparatus) && !hasApparatus + const reasonShown = SORT_FIELDS.some((option) => + blocked(option.needsApparatus), + ) + const fieldLabel = SORT_FIELDS.find((o) => o.field === sort.field)?.label + const directionLabel = SORT_DIRECTIONS.find( + (o) => o.direction === sort.direction, + )?.label + + return ( + + + } + > + + + + + + Sort by + + onChange({ ...sort, field: value as SortField }) + } + > + {SORT_FIELDS.map(({ field, label, needsApparatus }) => { + const disabled = blocked(needsApparatus) + return ( + + + + + + {label} + + ) + })} + + {reasonShown && ( +

+ Choose an apparatus to sort by difficulty. +

+ )} + + + + Direction + + onChange({ ...sort, direction: value as SortDirection }) + } + > + {SORT_DIRECTIONS.map(({ direction, label }) => ( + + + + + + {label} + + ))} + + + + + + + ) +} diff --git a/src/lib/searchOperations.ts b/src/lib/searchOperations.ts index d98f01b..1df8c5b 100644 --- a/src/lib/searchOperations.ts +++ b/src/lib/searchOperations.ts @@ -25,9 +25,72 @@ export const MAX_COUNTED = 1000 export const formatTotal = (totalCount: number): string => totalCount > MAX_COUNTED ? `${MAX_COUNTED}+` : String(totalCount) +export type SortField = 'CODE' | 'DIFFICULTY' | 'NAME' +export type SortDirection = 'ASC' | 'DESC' +export type Sort = { field: SortField; direction: SortDirection } + +export const DEFAULT_SORT: Sort = { field: 'CODE', direction: 'ASC' } + +/** + * The fields in the order of the menu, with the word the user sees. A new field is one + * more line here (and in the API): the menu and the address are built from this list. + * `needsApparatus` is for a field the API only allows within one apparatus. + */ +export const SORT_FIELDS: { + field: SortField + label: string + needsApparatus?: boolean +}[] = [ + { field: 'CODE', label: 'Code' }, + { field: 'DIFFICULTY', label: 'Difficulty', needsApparatus: true }, + { field: 'NAME', label: 'Name' }, +] + +export const SORT_DIRECTIONS: { direction: SortDirection; label: string }[] = [ + { direction: 'ASC', label: 'Ascending' }, + { direction: 'DESC', label: 'Descending' }, +] + +/** + * The sort as the address says it (`?sort=difficulty&dir=desc`). What is missing or not + * known is the default. Difficulty is only allowed with an apparatus (the API answers + * BAD_USER_INPUT without one: element values and vault D-values cannot be compared), so + * without one an address with `sort=difficulty` sorts by code. + */ +export function parseSort( + sort: string | null, + dir: string | null, + hasApparatus: boolean, +): Sort { + const option = SORT_FIELDS.find( + (candidate) => candidate.field.toLowerCase() === sort?.toLowerCase(), + ) + return { + field: + !option || (option.needsApparatus && !hasApparatus) + ? DEFAULT_SORT.field + : option.field, + direction: dir?.toLowerCase() === 'desc' ? 'DESC' : 'ASC', + } +} + +/** The sort as the address says it. The default is left out. */ +export function sortParams(sort: Sort): Record { + return { + ...(sort.field !== DEFAULT_SORT.field && { + sort: sort.field.toLowerCase(), + }), + ...(sort.direction !== DEFAULT_SORT.direction && { + dir: sort.direction.toLowerCase(), + }), + } +} + /** What the queries are sent with. `after` is the cursor of the page to load next. */ export type SearchVariables = { search: string | null + filter?: { apparatusId: string } | null + sort?: Sort first: number after?: string | null } @@ -101,8 +164,20 @@ export const SEARCH_CATALOG_QUERY: TypedDocumentNode< SearchCatalogResult, SearchVariables > = gql` - query SearchCatalog($search: String, $first: Int!, $after: String) { - searchCatalog(search: $search, first: $first, after: $after) { + query SearchCatalog( + $search: String + $filter: CatalogFilter + $sort: CatalogSort + $first: Int! + $after: String + ) { + searchCatalog( + search: $search + filter: $filter + sort: $sort + first: $first + after: $after + ) { totalCount pageInfo { hasNextPage @@ -127,8 +202,20 @@ export const SEARCH_CATALOG_MORE_QUERY: TypedDocumentNode< SearchCatalogMoreResult, SearchVariables > = gql` - query SearchCatalogMore($search: String, $first: Int!, $after: String) { - searchCatalog(search: $search, first: $first, after: $after) { + query SearchCatalogMore( + $search: String + $filter: CatalogFilter + $sort: CatalogSort + $first: Int! + $after: String + ) { + searchCatalog( + search: $search + filter: $filter + sort: $sort + first: $first + after: $after + ) { pageInfo { hasNextPage endCursor From 6da4b4f363bdd735f24d022a38932cca831c056b Mon Sep 17 00:00:00 2001 From: Ronja Date: Fri, 30 Oct 2026 17:07:56 +0100 Subject: [PATCH 02/13] feat: connect the filters on the Elements page --- src/components/elements/ElementsPage.test.tsx | 397 +++++++++++++++++- src/components/elements/ElementsPage.tsx | 146 ++++++- src/components/elements/FilterMenu.tsx | 135 ++++++ src/lib/elementFilters.ts | 145 +++++++ src/lib/searchOperations.ts | 31 +- 5 files changed, 822 insertions(+), 32 deletions(-) create mode 100644 src/components/elements/FilterMenu.tsx create mode 100644 src/lib/elementFilters.ts diff --git a/src/components/elements/ElementsPage.test.tsx b/src/components/elements/ElementsPage.test.tsx index e23bac5..5efb0b6 100644 --- a/src/components/elements/ElementsPage.test.tsx +++ b/src/components/elements/ElementsPage.test.tsx @@ -29,6 +29,7 @@ import { ok, type Answer, type Handler, + type Operation, } from '@/tests/apollo' import { exampleElementRows, exampleTotalCount } from '@/tests/elementRows' import ElementsPage from './ElementsPage' @@ -126,8 +127,54 @@ function GoTo({ to }: { to: string }) { ) } +/** What the server says to `CatalogStructure`: the apparatus with their groups. */ +const structure = [ + { + __typename: 'Apparatus', + id: 'VT', + name: 'Vault', + groups: [ + { + __typename: 'ElementGroup', + id: 'VT-1', + number: 1, + name: 'Long vault title', + }, + { + __typename: 'ElementGroup', + id: 'VT-2', + number: 2, + name: 'Another long title', + }, + ], + }, + { + __typename: 'Apparatus', + id: 'UB', + name: 'Uneven bars', + groups: [ + { __typename: 'ElementGroup', id: 'UB-1', number: 1, name: 'Mounts' }, + { __typename: 'ElementGroup', id: 'UB-2', number: 2, name: 'Dismounts' }, + ], + }, +] + +/** + * Starts the page. `calls` are the searches that were sent: the structure of the + * filters is answered here and is counted in `structureCalls`, so each test can look at + * the searches alone. + */ function renderPage(handler: Handler, address = '/elements', extra?: string) { - const { client, calls } = createTestClient(handler) + const calls: Operation[] = [] + const structureCalls: Operation[] = [] + const { client } = createTestClient((operation) => { + if (operation.operationName === 'CatalogStructure') { + structureCalls.push(operation) + return ok({ apparatus: structure }) + } + calls.push(operation) + return handler(operation) + }) render( @@ -137,7 +184,7 @@ function renderPage(handler: Handler, address = '/elements', extra?: string) { , ) - return { calls } + return { calls, structureCalls } } const field = () => screen.getByRole('searchbox') @@ -208,17 +255,12 @@ describe('ElementsPage: the page', () => { } }) - it('says so in a status when a filter or the sort is not connected yet, and not before', async () => { + it('says so in a status when the sort is not connected yet, and not before', async () => { renderPage(serverWithRows) await screen.findByText(/Showing/) const status = screen.getByTestId('controls-status') expect(status.textContent).toBe('') - fireEvent.click(screen.getByRole('button', { name: 'Apparatus' })) - expect(status.textContent).toBe( - 'The apparatus filter is not connected yet.', - ) - fireEvent.click( screen.getByRole('button', { name: /Sort by Code\W+Ascending/ }), ) @@ -226,6 +268,345 @@ describe('ElementsPage: the page', () => { }) }) +/** Opens a menu like a click does: the pointer goes down and up on the button. */ +async function openMenu(name: string | RegExp) { + const button = screen.getByRole('button', { name }) + fireEvent.pointerDown(button, { button: 0, pointerType: 'mouse' }) + fireEvent.mouseDown(button, { button: 0 }) + fireEvent.pointerUp(button, { button: 0, pointerType: 'mouse' }) + fireEvent.mouseUp(button, { button: 0 }) + fireEvent.click(button, { button: 0 }) + return screen.findByRole('menu') +} + +const choose = (role: 'menuitemradio' | 'menuitemcheckbox', name: string) => + fireEvent.click(screen.getByRole(role, { name })) + +const lastFilter = (calls: Operation[]) => calls.at(-1)!.variables['filter'] + +describe('ElementsPage: the filters', () => { + it('sends no filter when none is chosen, and keeps the address clean', async () => { + const { calls } = renderPage(serverWithRows) + await screen.findByText(/Showing/) + + expect(calls[0].variables['filter']).toBeNull() + expect(address()).toBe('/elements') + expect(screen.queryByRole('button', { name: 'Clear filters' })).toBeNull() + }) + + it('asks for the structure of the filters once, however many filters change', async () => { + const { structureCalls } = renderPage(serverWithRows) + await screen.findByText(/Showing/) + + await openMenu('Apparatus') + choose('menuitemradio', 'Uneven bars') + await waitFor(() => expect(address()).toContain('apparatus=UB')) + await screen.findByText(/Showing/) + + expect(structureCalls).toHaveLength(1) + }) + + it('filters by apparatus: sends it, puts it in the address, and says it on the button', async () => { + const { calls } = renderPage(serverWithRows) + await screen.findByText(/Showing/) + await openMenu('Apparatus') + choose('menuitemradio', 'Uneven bars') + + await waitFor(() => expect(address()).toBe('/elements?apparatus=UB')) + await waitFor(() => + expect(lastFilter(calls)).toEqual({ apparatusId: 'UB' }), + ) + expect( + screen.getByRole('button', { name: 'Apparatus: Uneven bars' }), + ).toBeTruthy() + }) + + it('has the apparatus as radio items, and the chosen one is checked', async () => { + renderPage(serverWithRows, '/elements?apparatus=UB') + await screen.findByText(/Showing/) + await openMenu(/Apparatus/) + + const checked = (name: string) => + screen.getByRole('menuitemradio', { name }).getAttribute('aria-checked') + expect(checked('Uneven bars')).toBe('true') + expect(checked('Vault')).toBe('false') + expect(checked('All apparatus')).toBe('false') + }) + + it('cannot choose a group before an apparatus, and says why', async () => { + renderPage(serverWithRows) + await screen.findByText(/Showing/) + await openMenu('Element group') + + expect( + screen.getByText('Choose an apparatus to choose a group.'), + ).toBeTruthy() + expect(screen.queryByRole('menuitemradio')).toBeNull() + }) + + it('filters by group of the chosen apparatus, and shows a vault group as Group N', async () => { + const { calls } = renderPage(serverWithRows, '/elements?apparatus=UB') + await screen.findByText(/Showing/) + await openMenu('Element group') + expect(screen.getByRole('menuitemradio', { name: 'Mounts' })).toBeTruthy() + expect(screen.queryByRole('menuitemradio', { name: 'Group 1' })).toBeNull() + choose('menuitemradio', 'Dismounts') + + await waitFor(() => + expect(address()).toBe('/elements?apparatus=UB&group=UB-2'), + ) + await waitFor(() => + expect(lastFilter(calls)).toEqual({ + apparatusId: 'UB', + elementGroupId: 'UB-2', + }), + ) + cleanup() + + renderPage(serverWithRows, '/elements?apparatus=VT') + await screen.findByText(/Showing/) + await openMenu('Element group') + expect(screen.getByRole('menuitemradio', { name: 'Group 1' })).toBeTruthy() + expect(screen.queryByText('Long vault title')).toBeNull() + }) + + it('clears the group when the apparatus changes', async () => { + const { calls } = renderPage( + serverWithRows, + '/elements?apparatus=UB&group=UB-2', + ) + await screen.findByText(/Showing/) + await openMenu(/Apparatus/) + choose('menuitemradio', 'Vault') + + await waitFor(() => expect(address()).toBe('/elements?apparatus=VT')) + await waitFor(() => + expect(lastFilter(calls)).toEqual({ apparatusId: 'VT' }), + ) + }) + + it('filters by one or more difficulty letters, sends the values, and keeps the menu open', async () => { + const { calls } = renderPage(serverWithRows, '/elements?apparatus=UB') + await screen.findByText(/Showing/) + await openMenu('Difficulty') + choose('menuitemcheckbox', 'B') + choose('menuitemcheckbox', 'A') + + expect(screen.getByRole('menu')).toBeTruthy() + await waitFor(() => + expect(address()).toBe('/elements?apparatus=UB&difficulty=A%2CB'), + ) + await waitFor(() => + expect(lastFilter(calls)).toEqual({ + apparatusId: 'UB', + difficultyValues: [0.1, 0.2], + }), + ) + expect( + screen + .getByRole('menuitemcheckbox', { name: 'A' }) + .getAttribute('aria-checked'), + ).toBe('true') + expect( + screen.getByRole('button', { name: 'Difficulty: A, B' }), + ).toBeTruthy() + + choose('menuitemcheckbox', 'A') + await waitFor(() => + expect(address()).toBe('/elements?apparatus=UB&difficulty=B'), + ) + }) + + it('does not offer difficulty for vault, and drops it when vault is chosen', async () => { + const { calls } = renderPage( + serverWithRows, + '/elements?apparatus=UB&difficulty=A', + ) + await screen.findByText(/Showing/) + await openMenu(/Apparatus/) + choose('menuitemradio', 'Vault') + + await waitFor(() => expect(address()).toBe('/elements?apparatus=VT')) + await waitFor(() => + expect(lastFilter(calls)).toEqual({ apparatusId: 'VT' }), + ) + cleanup() + + renderPage(serverWithRows, '/elements?apparatus=VT') + await screen.findByText(/Showing/) + await openMenu('Difficulty') + expect( + screen.getByText( + 'Vault has D-values, so there is no difficulty to filter by.', + ), + ).toBeTruthy() + expect(screen.queryByRole('menuitemcheckbox')).toBeNull() + }) + + it('can filter by difficulty without an apparatus', async () => { + const { calls } = renderPage(serverWithRows) + await screen.findByText(/Showing/) + await openMenu('Difficulty') + choose('menuitemcheckbox', 'C') + + await waitFor(() => + expect(lastFilter(calls)).toEqual({ difficultyValues: [0.3] }), + ) + }) + + it('starts from the filters in the address, together with the search', async () => { + const { calls } = renderPage( + serverWithRows, + '/elements?search=amanar&apparatus=UB&group=UB-1&difficulty=D,E', + ) + await screen.findByText(/Showing/) + + expect(calls).toHaveLength(1) + expect(calls[0].variables).toMatchObject({ + search: 'amanar', + filter: { + apparatusId: 'UB', + elementGroupId: 'UB-1', + difficultyValues: [0.4, 0.5], + }, + }) + expect( + screen.getByRole('button', { name: 'Apparatus: Uneven bars' }), + ).toBeTruthy() + expect( + screen.getByRole('button', { name: 'Element group: Mounts' }), + ).toBeTruthy() + expect( + screen.getByRole('button', { name: 'Difficulty: D, E' }), + ).toBeTruthy() + expect(field().value).toBe('amanar') + }) + + it('keeps the filters when the search changes, and the search when a filter changes', async () => { + renderPage(serverWithRows, '/elements?apparatus=UB') + await screen.findByText(/Showing/) + + fireEvent.change(field(), { target: { value: 'biles' } }) + await waitFor( + () => expect(address()).toBe('/elements?apparatus=UB&search=biles'), + wait, + ) + + await openMenu('Difficulty') + choose('menuitemcheckbox', 'A') + await waitFor(() => + expect(address()).toBe( + '/elements?apparatus=UB&search=biles&difficulty=A', + ), + ) + }) + + it('leaves out what the address says that does not exist or does not fit, so the API gets no BAD_USER_INPUT', async () => { + const { calls } = renderPage( + serverWithRows, + '/elements?apparatus=XX&group=VT-1&difficulty=A,Z', + ) + await screen.findByText(/Showing/) + // The group is of vault, so it gives the apparatus, and vault has no difficulty. + expect(calls[0].variables['filter']).toEqual({ + apparatusId: 'VT', + elementGroupId: 'VT-1', + }) + cleanup() + + const other = renderPage( + serverWithRows, + '/elements?apparatus=UB&group=VT-1&difficulty=a,zz', + ) + await screen.findByText(/Showing/) + expect(other.calls[0].variables['filter']).toEqual({ + apparatusId: 'UB', + difficultyValues: [0.1], + }) + }) + + it('does not search before the structure is known when the address has a filter, so a wrong value is never sent', async () => { + const wait1 = deferred() + const calls: Operation[] = [] + const { client } = createTestClient((operation) => { + calls.push(operation) + return operation.operationName === 'CatalogStructure' + ? wait1.promise + : searchAnswer(exampleElementRows) + }) + render( + + + + + , + ) + + expect(await screen.findByText('Loading elements…')).toBeTruthy() + expect(calls.map((c) => c.operationName)).toEqual(['CatalogStructure']) + + wait1.resolve(ok({ apparatus: structure })) + await screen.findByText(/Showing/) + expect(calls.map((c) => c.operationName)).toEqual([ + 'CatalogStructure', + 'SearchCatalog', + ]) + }) + + it('clears every filter at once, and keeps the search', async () => { + const { calls } = renderPage( + serverWithRows, + '/elements?search=amanar&apparatus=UB&group=UB-1&difficulty=D', + ) + await screen.findByText(/Showing/) + fireEvent.click(screen.getByRole('button', { name: 'Clear filters' })) + + await waitFor(() => expect(address()).toBe('/elements?search=amanar')) + await waitFor(() => expect(lastFilter(calls)).toBeNull()) + expect(screen.queryByRole('button', { name: 'Clear filters' })).toBeNull() + }) + + it('follows the address when the user goes to another one, like the back button does', async () => { + renderPage(serverWithRows, '/elements', '/elements?apparatus=UB') + await screen.findByText(/Showing/) + fireEvent.click( + screen.getByRole('button', { name: 'go to /elements?apparatus=UB' }), + ) + + await waitFor(() => expect(address()).toBe('/elements?apparatus=UB')) + await screen.findByRole('button', { name: 'Clear filters' }) + }) + + it('starts a changed filter from the first page, and loads more with the same filter', async () => { + const { calls } = renderPage(pagedServer(4)) + await screen.findByText(/Showing 4 of/) + fireEvent.click(screen.getByRole('button', { name: 'Load more elements' })) + await screen.findByText(/Showing 7 of/) + + await openMenu('Apparatus') + choose('menuitemradio', 'Uneven bars') + await screen.findByText(/Showing 4 of/) + const last = calls.at(-1)! + expect(last.operationName).toBe('SearchCatalog') + expect(last.variables).not.toHaveProperty('after') + expect(last.variables['filter']).toEqual({ apparatusId: 'UB' }) + + fireEvent.click(screen.getByRole('button', { name: 'Load more elements' })) + await screen.findByText(/Showing 7 of/) + expect(calls.at(-1)!.variables).toMatchObject({ + filter: { apparatusId: 'UB' }, + after: expect.any(String), + }) + }) + + it('says that nothing matches the filters', async () => { + renderPage(() => searchAnswer([]), '/elements?apparatus=UB') + expect( + await screen.findByText('No elements match these filters.'), + ).toBeTruthy() + }) +}) + describe('ElementsPage: the results from the API', () => { it('asks for the first page, with no search text, and shows what the server says', async () => { const { calls } = renderPage(serverWithRows) diff --git a/src/components/elements/ElementsPage.tsx b/src/components/elements/ElementsPage.tsx index e84e201..56d686b 100644 --- a/src/components/elements/ElementsPage.tsx +++ b/src/components/elements/ElementsPage.tsx @@ -1,10 +1,27 @@ import { useEffect, useRef, useState, type FormEvent } from 'react' import { useQuery } from '@apollo/client/react' import { useSearchParams } from 'react-router-dom' -import { ArrowUpDown, ChevronDown, Plus, Search } from 'lucide-react' +import { ArrowUpDown, Plus, Search, X } from 'lucide-react' +import { + MultiFilterMenu, + SingleFilterMenu, +} from '@/components/elements/FilterMenu' import ResultsTable from '@/components/elements/ResultsTable' import { Button } from '@/components/ui/button' import { describeError } from '@/lib/apollo/errors' +import { + asksForFilters, + DIFFICULTY_LETTERS, + groupName, + hasFilters, + NO_FILTERS, + parseFilters, + STRUCTURE_QUERY, + toCatalogFilter, + VAULT_ID, + withFilters, + type ElementFilters, +} from '@/lib/elementFilters' import { formatTotal, MAX_SEARCH_LENGTH, @@ -18,10 +35,7 @@ import { const PILL = 'h-10 rounded-full border-0 bg-(--button-bg) px-4 text-sm font-medium text-(--button-text) shadow-(--box-shadow) transition-colors hover:bg-(--button-hover-bg) focus-visible:bg-(--button-hover-bg) dark:border-transparent dark:bg-(--button-bg) dark:hover:bg-(--button-hover-bg) hover:text-(--button-text) dark:hover:text-(--button-text) focus-visible:ring-0 focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-(--text-color) motion-reduce:transition-none' -const FILTERS = ['Apparatus', 'Element group', 'Difficulty'] - -// TODO(#110): the filters and the sort are only drawn so far. Each one says that it is -// not connected yet, like the buttons on the element page. +// TODO(#110): the sort is only drawn so far. It says that it is not connected yet. export default function ElementsPage() { const [status, setStatus] = useState('') const [searchParams, setSearchParams] = useSearchParams() @@ -38,7 +52,16 @@ export default function ElementsPage() { const trimmed = value.trim() if (trimmed === written.current) return written.current = trimmed - setSearchParams(trimmed ? { search: trimmed } : {}, { replace: true }) + // Writing the search keeps the filters in the address. + setSearchParams( + (params) => { + const next = new URLSearchParams(params) + if (trimmed) next.set('search', trimmed) + else next.delete('search') + return next + }, + { replace: true }, + ) } useEffect(() => { @@ -56,9 +79,38 @@ export default function ElementsPage() { } }, [search]) + // The filters are in the address too (?apparatus=UB&group=UB-2&difficulty=A,B). What + // they choose from (the apparatus and their groups) is a small list that is asked for + // once and kept. An address with a filter waits for it, so that a value that does not + // exist is left out and not sent to the API. + const structureQuery = useQuery(STRUCTURE_QUERY) + const structure = structureQuery.data?.apparatus ?? [] + const structureFailed = Boolean(structureQuery.error) + const filtersPending = + asksForFilters(searchParams) && !structureQuery.data && !structureFailed + const filters = parseFilters(searchParams, structure) + + // A changed filter is a new list (the cache keys it), so it starts from the first + // page. It is a new step in the history, so the back button undoes it. + function changeFilters(next: ElementFilters) { + setSearchParams((params) => withFilters(params, next)) + } + + const apparatus = structure.find((a) => a.id === filters.apparatusId) + const group = apparatus?.groups.find((g) => g.id === filters.groupId) + + // What the search is sent with. `after` is added by "Load more". + const searchVariables = { + search: search || null, + filter: toCatalogFilter(filters), + } + const { data, previousData, loading, error, refetch, fetchMore } = useQuery( SEARCH_CATALOG_QUERY, - { variables: { search: search || null, first: PAGE_SIZE } }, + { + variables: { ...searchVariables, first: PAGE_SIZE }, + skip: filtersPending, + }, ) // While a new search is on its way the old results stay, so the page does not jump. const shown = (data ?? previousData)?.searchCatalog @@ -82,7 +134,7 @@ export default function ElementsPage() { try { await fetchMore({ query: SEARCH_CATALOG_MORE_QUERY, - variables: { search: search || null, first: PAGE_SIZE, after }, + variables: { ...searchVariables, first: PAGE_SIZE, after }, }) } catch (problem) { setMoreError({ search, message: describeError(problem).message }) @@ -127,22 +179,74 @@ export default function ElementsPage() {
- {FILTERS.map((filter) => ( + ({ value: a.id, label: a.name }))} + unavailable={ + structureFailed ? 'The apparatus could not be loaded.' : undefined + } + className={PILL} + onChange={(id) => + // The group belongs to the apparatus, and vault has no difficulty. + changeFilters({ + apparatusId: id || null, + groupId: null, + letters: id === VAULT_ID ? [] : filters.letters, + }) + } + /> + ({ + value: g.id, + label: groupName(apparatus!.id, g), + }))} + unavailable={ + apparatus ? undefined : 'Choose an apparatus to choose a group.' + } + className={PILL} + onChange={(id) => + changeFilters({ ...filters, groupId: id || null }) + } + /> + ({ + value: letter, + label: letter, + }))} + unavailable={ + filters.apparatusId === VAULT_ID + ? 'Vault has D-values, so there is no difficulty to filter by.' + : undefined + } + className={PILL} + onChange={(letters) => + changeFilters({ + ...filters, + letters: DIFFICULTY_LETTERS.filter((l) => letters.includes(l)), + }) + } + /> + {hasFilters(filters) && ( - ))} + )}
From 7edbfc6934bd4306cf3fea5bd3d29cac4c309629 Mon Sep 17 00:00:00 2001 From: Ronja Date: Fri, 30 Oct 2026 19:11:25 +0100 Subject: [PATCH 12/13] fix: give every page a title that says what the page is --- index.html | 2 +- src/App.test.tsx | 18 ++++++++++++++- src/App.tsx | 4 ++++ src/components/PageTitle.tsx | 15 ++++++++++++ .../elements/ElementDetailPage.test.tsx | 23 +++++++++++++++++++ src/components/elements/ElementDetailPage.tsx | 8 ++++++- src/components/elements/ElementsPage.test.tsx | 6 +++++ src/components/elements/ElementsPage.tsx | 2 ++ .../gymnasts/YourGymnastsPage.test.tsx | 6 +++++ src/components/gymnasts/YourGymnastsPage.tsx | 2 ++ 10 files changed, 83 insertions(+), 3 deletions(-) create mode 100644 src/components/PageTitle.tsx diff --git a/index.html b/index.html index 626211a..4676c48 100644 --- a/index.html +++ b/index.html @@ -10,7 +10,7 @@ href="https://fonts.googleapis.com/css2?family=Faustina:wght@400;500;600;700;800&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" /> - t29-project-2 + WAG CoP Planner
diff --git a/src/App.test.tsx b/src/App.test.tsx index 34958c7..52730c2 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { ApolloProvider } from '@apollo/client/react'; -import { fireEvent, render, screen } from '@testing-library/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 } from '@/tests/apollo'; @@ -84,6 +84,22 @@ describe('App', () => { expect(screen.getAllByRole('main')).toHaveLength(1); }); + 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'); + + 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'); + + 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`); diff --git a/src/App.tsx b/src/App.tsx index 10645b5..34da14f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,6 +3,7 @@ import { Navigate, Route, Routes } from 'react-router-dom'; import { Navbar } from './components/navbar/Navbar'; import { SkipLink } from './components/skipLink/SkipLink'; import { ProtectedRoute } from './components/ProtectedRoute'; +import PageTitle from './components/PageTitle'; import ElementDetailPage from './components/elements/ElementDetailPage'; import ElementsPage from './components/elements/ElementsPage'; @@ -16,16 +17,19 @@ import './styles/global.css'; // has its own
, like the real pages. const CalculatorPage = () => (
+ Calculator

Calculator

); const AboutPage = () => (
+ About

About

); const NotFoundPage = () => (
+ Page not found

404 - Page not found

); diff --git a/src/components/PageTitle.tsx b/src/components/PageTitle.tsx new file mode 100644 index 0000000..ba7e044 --- /dev/null +++ b/src/components/PageTitle.tsx @@ -0,0 +1,15 @@ +/** The name of the site, at the end of every page title. */ +export const SITE_NAME = 'WAG CoP Planner' + +/** + * The title of the page in the browser tab, the history and the bookmarks, and the first + * thing a screen reader says when a page opens (WCAG 2.4.2). It says what the page is, and + * then the site: "Elements – WAG CoP Planner". React 19 moves a `` to the head of the + * document wherever it is rendered, so no library is needed, and the last one rendered + * wins. Every page renders one, also while it is loading or has failed, because the address + * has already changed and the old title would be wrong. + */ +export default function PageTitle({ children }: { children: string }) { + // One string: React 19 only accepts a single string as the child of a <title>. + return <title>{`${children} – ${SITE_NAME}`} +} diff --git a/src/components/elements/ElementDetailPage.test.tsx b/src/components/elements/ElementDetailPage.test.tsx index 3b3a427..f8c9ca8 100644 --- a/src/components/elements/ElementDetailPage.test.tsx +++ b/src/components/elements/ElementDetailPage.test.tsx @@ -66,6 +66,29 @@ const button = (name: string) => afterEach(cleanup) describe('ElementDetailPage', () => { + it('has a title for each state: loading, the element, not found and the error', async () => { + renderPage('UB-2.305-a', (operation) => elementAnswer(idOf(operation))) + // The title is there at once, while the element is on its way. + expect(document.title).toBe('Loading element – WAG CoP Planner') + await screen.findByRole('heading', { level: 1, name: '2.305-a' }) + // The code with the letter of the variant, and the apparatus: two tabs can be told apart. + expect(document.title).toBe( + 'Element 2.305-a – Uneven bars – WAG CoP Planner', + ) + cleanup() + + renderPage('UB-9.999-z', () => ok({ catalogItem: null })) + await screen.findByRole('heading', { name: 'Element not found' }) + expect(document.title).toBe('Element not found – WAG CoP Planner') + cleanup() + + renderPage('UB-2.305-a', () => + failure('INTERNAL_SERVER_ERROR', 'Something broke'), + ) + await screen.findByRole('heading', { name: 'Element details unavailable' }) + expect(document.title).toBe('Element details unavailable – WAG CoP Planner') + }) + it('shows the element from the API, with its drawing and a link to the variant', async () => { renderPage('UB-2.305-a', (operation) => elementAnswer(idOf(operation))) diff --git a/src/components/elements/ElementDetailPage.tsx b/src/components/elements/ElementDetailPage.tsx index 1f0f4b4..1c6e1ec 100644 --- a/src/components/elements/ElementDetailPage.tsx +++ b/src/components/elements/ElementDetailPage.tsx @@ -6,9 +6,10 @@ import ElementActions from '@/components/elements/ElementActions' import ElementDetailHeader from '@/components/elements/ElementDetailHeader' import ElementDiagram from '@/components/elements/ElementDiagram' import ElementMetadataCard from '@/components/elements/ElementMetadataCard' +import PageTitle from '@/components/PageTitle' import ElementVariants from '@/components/ui/ElementVariants' import { describeError, type DescribedError } from '@/lib/apollo/errors' -import { toElementDetail } from '@/lib/catalog' +import { displayCode, toElementDetail } from '@/lib/catalog' import { CATALOG_ITEM_QUERY } from '@/lib/catalogOperations' export default function ElementDetailPage() { @@ -40,6 +41,7 @@ export default function ElementDetailPage() { return (
+ Element details unavailable

Element details unavailable

{message} @@ -65,6 +67,7 @@ export default function ElementDetailPage() { aria-busy="true" className="element-detail-page min-h-svh bg-(--secondary-color) px-5 py-8 text-left sm:px-8 lg:px-12" > + Loading element

Loading element details

@@ -81,6 +84,7 @@ export default function ElementDetailPage() { return (
+ Element not found

Element not found

No catalog item matches this address. @@ -103,6 +107,8 @@ export default function ElementDetailPage() { aria-busy={loading} className="element-detail-page min-h-svh bg-(--secondary-color) px-5 py-6 text-left sm:px-8 sm:py-10 lg:px-12" > + {/* 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}`}

{element.removed && ( diff --git a/src/components/elements/ElementsPage.test.tsx b/src/components/elements/ElementsPage.test.tsx index 49f3d46..15ee94f 100644 --- a/src/components/elements/ElementsPage.test.tsx +++ b/src/components/elements/ElementsPage.test.tsx @@ -201,6 +201,12 @@ const address = () => screen.getByTestId('address').textContent const wait = { timeout: 3000 } describe('ElementsPage: the page', () => { + it('has a title that says what the page is (WCAG 2.4.2)', async () => { + renderPage(serverWithRows) + await screen.findByText(/Showing/) + expect(document.title).toBe('Elements – WAG CoP Planner') + }) + it('has one h1, and the results under an h2 that names the section', async () => { renderPage(serverWithRows) diff --git a/src/components/elements/ElementsPage.tsx b/src/components/elements/ElementsPage.tsx index be32f6c..4add436 100644 --- a/src/components/elements/ElementsPage.tsx +++ b/src/components/elements/ElementsPage.tsx @@ -12,6 +12,7 @@ import { MultiFilterMenu, SingleFilterMenu, } from '@/components/elements/FilterMenu' +import PageTitle from '@/components/PageTitle' import ResultsTable from '@/components/elements/ResultsTable' import SortMenu from '@/components/elements/SortMenu' import { Button } from '@/components/ui/button' @@ -217,6 +218,7 @@ export default function ElementsPage() { return (
+ Elements

Elements

{ + it('has a title that says what the page is (WCAG 2.4.2)', async () => { + renderPage(() => coachWith(['Emma Berg'])) + await screen.findByText(/Emma Berg/) + expect(document.title).toBe('Your gymnasts – WAG CoP Planner') + }) + it('shows a loading state until the server answers', async () => { const answer = deferred() renderPage(() => answer.promise) diff --git a/src/components/gymnasts/YourGymnastsPage.tsx b/src/components/gymnasts/YourGymnastsPage.tsx index 2e99f2f..a9d1aff 100644 --- a/src/components/gymnasts/YourGymnastsPage.tsx +++ b/src/components/gymnasts/YourGymnastsPage.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from "react"; import { useMutation, useQuery } from "@apollo/client/react"; import { Plus, X } from "lucide-react"; +import PageTitle from "@/components/PageTitle"; import { Button } from "@/components/ui/button"; import DevLoginButton from "@/components/gymnasts/DevLoginButton"; import GymnastList from "@/components/gymnasts/GymnastList"; @@ -87,6 +88,7 @@ export default function YourGymnastsPage() { return (
+ Your gymnasts

{announcement} From 83f2b992214a9209197c411ebb651b5dd1da552a Mon Sep 17 00:00:00 2001 From: Ronja Date: Fri, 30 Oct 2026 19:11:25 +0100 Subject: [PATCH 13/13] docs: describe the Elements page, the vault bands, the sort and the accessibility work --- README.md | 50 +++++++++++++++++++++++++++++++++++++++++++----- docs/AI_USAGE.md | 18 +++++++++++++---- 2 files changed, 59 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 39b9ced..6c1ae1c 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,9 @@ We are building a web application that helps gymnastics coaches plan their gymna ## Status -The first delivery was a specification. The application is not finished: the frontend is still the Vite starter page, but the catalog data has been extracted from the Code of Points and is in [`data/`](data/), and the database schema is in place: the Prisma schema and its migrations create all the tables of the data model in PostgreSQL. The seed script loads `data/` into the database, and a GraphQL server in `backend/` answers the catalog queries and the search (see [GraphQL server](#graphql-server)). The login and the user data (gymnasts, their lists of elements and routines) work through the API. The connection from the frontend is the next step. +The first delivery was a specification. The application is not finished. The catalog data has been extracted from the Code of Points and is in [`data/`](data/), the database schema and migrations are in place, the seed script loads `data/` into PostgreSQL, and a GraphQL server in `backend/` answers the catalog queries and the search (see [GraphQL server](#graphql-server)). The login and the user data (gymnasts, their lists of elements and routines) work through the API. -The code in `src/` is the Vite starter project with one example test. The tooling is set up: TypeScript, ESLint, Prettier and Vitest. +The frontend is connected to the API: the **Elements page** searches the catalog with filters, sorting and "Load more" (see [The Elements page](#the-elements-page-search-filters-and-sort)), each element has a detail page, and "Your gymnasts" lists and adds gymnasts. The calculator and the About page are still placeholders, and the Google login in the interface is not connected yet. The tooling is set up: TypeScript, ESLint, Prettier and Vitest. ## What to review @@ -144,7 +144,7 @@ An icon without text needs a label for screen readers, for example `