diff --git a/backend/src/catalog/search.test.ts b/backend/src/catalog/search.test.ts index 71adfbb..1cb75a0 100644 --- a/backend/src/catalog/search.test.ts +++ b/backend/src/catalog/search.test.ts @@ -12,6 +12,7 @@ import { BadUserInputError, type CatalogSearchArgs, listElementGroups, + MAX_TOTAL_COUNT, searchCatalog, searchTerms, } from './search.js' @@ -502,4 +503,54 @@ describe.runIf(hasDatabase)('searchCatalog on the seeded catalog', () => { const page = await searchCatalog(prisma, { first: 1 }) expect(page.totalCount).toBeUndefined() }) + + describe('the count stops at the most that is counted', () => { + const count = async (maxTotalCount?: number, search?: string) => + ( + await searchCatalog(prisma, { + first: 1, + search, + withTotalCount: true, + maxTotalCount, + }) + ).totalCount + + it('counts up to 1000 as it is, and the catalog is smaller than that', async () => { + expect(MAX_TOTAL_COUNT).toBe(1000) + expect(totalEntries).toBeLessThanOrEqual(MAX_TOTAL_COUNT) + expect(await count()).toBe(totalEntries) + }) + + it('stops at one more than the most, so a count above it means "more than that"', async () => { + expect(totalEntries).toBeGreaterThan(10) + expect(await count(10)).toBe(11) + expect(await count(1)).toBe(2) + }) + + it('is not capped when the most is as big as the number of matches, or bigger', async () => { + expect(await count(totalEntries)).toBe(totalEntries) + expect(await count(totalEntries + 500)).toBe(totalEntries) + // The most that is one less than the number of matches: one more than it is the + // number of matches, which is above the most. + expect(await count(totalEntries - 1)).toBe(totalEntries) + }) + + it('caps a search too, and a search with fewer matches than the most is counted as it is', async () => { + const all = await searchCatalog(prisma, { + first: 100, + search: 'handspring', + withTotalCount: true, + }) + expect(all.totalCount).toBeGreaterThan(5) + + expect(await count(5, 'handspring')).toBe(6) + expect(await count(all.totalCount! + 10, 'handspring')).toBe( + all.totalCount, + ) + }) + + it('counts nothing for text with no searchable words, also with a cap', async () => { + expect(await count(10, '%')).toBe(0) + }) + }) }) diff --git a/backend/src/catalog/search.ts b/backend/src/catalog/search.ts index 6115d75..3b806fe 100644 --- a/backend/src/catalog/search.ts +++ b/backend/src/catalog/search.ts @@ -36,19 +36,33 @@ export type CatalogSearchArgs = { /** Page size, 1–100. */ first?: number | null after?: string | null - /** Count all matches. Only set it when the client asks for totalCount. */ + /** Count the matches. Only set it when the client asks for totalCount. */ withTotalCount?: boolean + /** + * The most matches that are counted, MAX_TOTAL_COUNT if it is left out. The count + * stops at one more than this, so a count above it means "more than this many". + */ + maxTotalCount?: number } export type CatalogSearchResult = { edges: { cursor: string; node: CatalogEntry }[] pageInfo: { hasNextPage: boolean; endCursor: string | null } - /** Only set when withTotalCount was true. */ + /** + * Only set when withTotalCount was true. At most maxTotalCount + 1: a value above + * maxTotalCount means that there are more matches than that. + */ totalCount?: number } const DIRECTIONS: readonly SortDirection[] = ['ASC', 'DESC'] const MAX_PAGE_SIZE = 100 +/** + * How many matches `totalCount` counts. A search that has more matches gets one more + * than this (1001), which the interface shows as "1000+". The database stops counting + * there, so the cost of the count does not grow with the catalog. + */ +export const MAX_TOTAL_COUNT = 1000 /** Limits for the search text. Far more than anyone types, and each word adds a condition to the query. */ export const MAX_SEARCH_LENGTH = 80 export const MAX_SEARCH_WORDS = 8 @@ -237,9 +251,15 @@ export async function searchCatalog( endCursor: edges.at(-1)?.cursor ?? null, }, } - // Counting reads every match, so it only runs when the client asks for it. - if (args.withTotalCount) - result.totalCount = await prisma.catalogEntry.count({ where }) + // Counting reads the matches, so it only runs when the client asks for it, and it + // stops after one more than the most that is counted (take). + if (args.withTotalCount) { + const max = args.maxTotalCount ?? MAX_TOTAL_COUNT + result.totalCount = await prisma.catalogEntry.count({ + where, + take: max + 1, + }) + } return result } diff --git a/backend/src/schema.graphql b/backend/src/schema.graphql index 6dfa941..d3180e6 100644 --- a/backend/src/schema.graphql +++ b/backend/src/schema.graphql @@ -205,7 +205,8 @@ type CatalogConnection { """ Number of matches in the whole result set, not only this page. Counting is expensive on a large result, so the server only counts when the client asks - for this field. + for this field, and it stops at 1001: a value above 1000 means that there are + more than 1000 matches (the interface shows "1000+"). """ totalCount: Int! } diff --git a/docs/AI_USAGE.md b/docs/AI_USAGE.md index 57ddbf7..a97dde8 100644 --- a/docs/AI_USAGE.md +++ b/docs/AI_USAGE.md @@ -447,6 +447,19 @@ id, a stopped server and "Try again" work. The JavaScript bundle went from 665 kB to 522 kB. +## The Elements page (`src/components/elements/ElementsPage.tsx`, `ResultsTable.tsx`) + +- **Tool:** Claude Code +- **What the team did:** Drew the page in a design (`elements_page.png`) and + chose that it should look exactly like it, without lines between the rows. Decided that this pull request is only the + design, and that the search, filters, sort and paging are connected in #110. +- **What Claude did:** Measured the colors in the design and used the existing + tokens for all of them but the purple of every other row (new: + `--table-row-alt`). Wrote the page and the table with the existing badge for + the circle. Checked the page at 390, 768, 927 and 1440 pixels wide in a + browser, and chose how the table becomes cards on a phone and how the whole + row is one link. Wrote the tests. + ## Tests for the element page (`src/components/elements/ElementDetailPage.test.tsx`, `src/tests/apollo.ts`) - **Tool:** Claude Code @@ -460,3 +473,21 @@ page one at a time (the drawing, the warning, the disabled buttons, the not-found page, "Try again", "Trying again…", keeping the current element and the variant link) and checked that a test failed each time. + +## The search and the results of the Elements page (`src/components/elements/ElementsPage.tsx`, `src/lib/searchOperations.ts`) + +- **Tool:** Claude Code +- **What the team did:** Decided that making the page work is done in parts, each with its own issue and pull request, and that the first part is the search field and the results (#117). Chose that the pull request is stacked on the design (#114) until that is merged. +- **What Claude did:** Wrote the query `searchCatalog` and the page with the results, the loading, error and empty states, the debounce and the search in the address. Moved the example rows to a fixture for the tests. Wrote the tests on the real Apollo Client with a stub for `fetch`, and checked them by breaking five behaviors one at a time (the tests fail without each of them). Checked in a browser against the real backend: the first 20 of 562, a search, no result, a search from the address, and 9 words. + +## Load more elements (`src/components/elements/ElementsPage.tsx`, `src/lib/searchOperations.ts`) + +- **Tool:** Claude Code +- **What the team did:** Decided that "Load more elements" is disabled when everything is shown ("Showing N of N"), and that all the work to make the page work is one issue (#110), in several pull requests. +- **What Claude did:** Wrote the query for the next page (without `totalCount`, which the API counts only when it is asked), the button with its states (loading, disabled when everything is shown, error with a new try) and the tests, and checked the tests by breaking six behaviors one at a time. Checked in a browser against the real backend: 20, 40 and 60 of 562, one result (disabled at once), and 36 of 36 for a search that has two pages. + +## The count of the search stops at 1001 (`backend/src/catalog/search.ts`) + +- **Tool:** Claude Code +- **What the team did:** Decided that the page says "1000+" when there are more than 1000 results, and that the server must not count more than that. +- **What Claude did:** Made the database stop counting at 1001 (`count` with `take`), kept the schema the same (a `totalCount` above 1000 means more than 1000), wrote the tests (the count stops at one more than the most, it is not capped below that, and a search is capped too) and broke the cap to check that they fail without it. diff --git a/docs/API.md b/docs/API.md index e4bc931..7b61a51 100644 --- a/docs/API.md +++ b/docs/API.md @@ -158,7 +158,7 @@ The requirement is that the solution works with any amount of data. The catalog - **Filter and sort need indexes.** `schema.prisma` declares an index for each sort field combined with `id` (`codeSort`, `difficulty`, and `hasName` with `nameSort`), and for the filter columns (apparatus, group). Without them the database sorts the whole catalog for every request. The name sort puts named items first in both directions (`hasName` descending), so ascending and descending each have their own index. The apparatus filter is often combined with a sort, so there are also indexes on apparatus with `difficulty` and with `codeSort`. The difficulty sort always has an apparatus filter, so it uses the index on apparatus and `difficulty`. The difficulty filter without an apparatus uses the index on `difficulty`. Without them, a filtered page walks past the rows of the other apparatus first, which is slow in one direction (for example vault sorted by ascending difficulty). - **Sort by name needs a rule for items without a name.** Most items have no name. A cursor does not accept `nulls` placement, so the sort uses a plain `hasName` field and a `nameSort` field that is never `null` (see Search without raw SQL). - **The text search must not scan the table.** A search for `%word%` cannot use an ordinary index. The search uses a word table with a B-tree index and a prefix match, which finds the rows that have a word starting with the search term without reading every row. A search that is correct but reads every row works on 500 rows and not on 5 million. -- **`totalCount` is only calculated when the client asks for it.** Counting a large result takes time: about 30 ms for the whole catalog and about 100 ms for a search in a test with 225 000 entries, and the cost grows with the catalog. The resolver for the field runs only if `totalCount` is in the query, so a client that scrolls without showing a total does not pay for it. The client should therefore ask for `totalCount` only with the first page of a new search or filter, and not with the pages that are loaded while scrolling. Together with the debounce on the search field, this keeps the count from running on every keystroke. If the count is still too slow, it can be capped (for example "1000+"), which needs a change in the schema. +- **`totalCount` is only calculated when the client asks for it.** Counting a large result takes time: about 30 ms for the whole catalog and about 100 ms for a search in a test with 225 000 entries, and the cost grows with the catalog. The resolver for the field runs only if `totalCount` is in the query, so a client that scrolls without showing a total does not pay for it. The client should therefore ask for `totalCount` only with the first page of a new search or filter, and not with the pages that are loaded while scrolling. Together with the debounce on the search field, this keeps the count from running on every keystroke. **The count is capped:** it stops at 1001 (`MAX_TOTAL_COUNT` is 1000, and the database counts one more), so the cost does not grow with the catalog. A `totalCount` above 1000 means that there are more than 1000 matches, and the interface shows "1000+". The schema is the same (`Int!`), only the meaning of a value above 1000 is new. - **Elements and vaults are different tables, but one list.** A query cannot combine two tables into one sorted and paged result. The data model keeps them apart because they work differently, so the list is read from `CatalogEntry`, a read-only table with the shared columns that the seed script fills. It never gets out of date because the catalog never changes between seed runs. - **Variants are few.** A variant group has two or three members, so `variants` does not need paging. diff --git a/index.html b/index.html index 8cd354a..626211a 100644 --- a/index.html +++ b/index.html @@ -7,7 +7,7 @@ t29-project-2 diff --git a/src/App.test.tsx b/src/App.test.tsx index f161539..34958c7 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -84,14 +84,30 @@ describe('App', () => { expect(screen.getAllByRole('main')).toHaveLength(1); }); + 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(); + + 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(); + } + }); + it('guards the Gymnasts route and makes it available after login', () => { window.history.replaceState({}, '', `${import.meta.env.BASE_URL}gymnasts`); renderApp(); - // Logged out, /gymnasts goes to the start page, which shows an element. - expect(window.location.pathname).toBe( - `${import.meta.env.BASE_URL}elements/UB-1.101-a`, - ); + // Logged out, /gymnasts goes to the start page, which is the Elements page. + expect(window.location.pathname).toBe(`${import.meta.env.BASE_URL}elements`); expect(screen.queryByRole('link', { name: 'Gymnasts' })).toBeNull(); fireEvent.click(screen.getByRole('button', { name: 'Log in' })); diff --git a/src/App.tsx b/src/App.tsx index c2bdae4..10645b5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,6 +5,7 @@ import { SkipLink } from './components/skipLink/SkipLink'; import { ProtectedRoute } from './components/ProtectedRoute'; import ElementDetailPage from './components/elements/ElementDetailPage'; +import ElementsPage from './components/elements/ElementsPage'; import YourGymnastsPage from './components/gymnasts/YourGymnastsPage'; import './index.css'; @@ -14,17 +15,17 @@ import './styles/global.css'; // Placeholder pages for routes without full implementations yet. Each page // has its own
, like the real pages. const CalculatorPage = () => ( -
+

Calculator

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

About

); const NotFoundPage = () => ( -
+

404 - Page not found

); @@ -56,10 +57,8 @@ export function App() { {/* The pages have their own
, so this is only the target of the skip link. */}
- } - /> + } /> + } /> } /> } /> diff --git a/src/components/elements/ElementDetailPage.tsx b/src/components/elements/ElementDetailPage.tsx index 2aa5e34..1f0f4b4 100644 --- a/src/components/elements/ElementDetailPage.tsx +++ b/src/components/elements/ElementDetailPage.tsx @@ -38,8 +38,8 @@ export default function ElementDetailPage() { if (problem) { const { message, retryable } = problem return ( -
-
+
+

Element details unavailable

{message} @@ -63,12 +63,12 @@ export default function ElementDetailPage() { return (

Loading element details

-
+
@@ -79,8 +79,8 @@ export default function ElementDetailPage() { if (!item) { return ( -
-
+
+

Element not found

No catalog item matches this address. @@ -101,9 +101,9 @@ export default function ElementDetailPage() { return (

-
+
{element.removed && (

0)}${element.name ? `: ${element.name}` : ''}`} - className="block h-auto w-full object-contain" + 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 da87a2c..19768e0 100644 --- a/src/components/elements/ElementMetadataCard.tsx +++ b/src/components/elements/ElementMetadataCard.tsx @@ -38,10 +38,10 @@ export default function ElementMetadataCard({

- + {element.isVault || !difficulty ? ( ) : ( diff --git a/src/components/elements/ElementsPage.test.tsx b/src/components/elements/ElementsPage.test.tsx new file mode 100644 index 0000000..e23bac5 --- /dev/null +++ b/src/components/elements/ElementsPage.test.tsx @@ -0,0 +1,577 @@ +// @vitest-environment jsdom +// The page runs on the real Apollo Client, with a stub for `fetch` that answers like +// the server (see src/tests/apollo.ts and "Search, filter, sort and pagination" in +// docs/API.md). + +import { ApolloProvider } from '@apollo/client/react' +import { + cleanup, + fireEvent, + render, + screen, + waitFor, + within, +} from '@testing-library/react' +import { print } from 'graphql' +import { MemoryRouter, useLocation, useNavigate } from 'react-router-dom' +import { afterEach, describe, expect, it } from 'vitest' +import type { ElementListRow } from '@/lib/elementList' +import { + MAX_SEARCH_LENGTH, + PAGE_SIZE, + SEARCH_CATALOG_MORE_QUERY, + SEARCH_CATALOG_QUERY, +} from '@/lib/searchOperations' +import { + createTestClient, + deferred, + failure, + ok, + type Answer, + type Handler, +} from '@/tests/apollo' +import { exampleElementRows, exampleTotalCount } from '@/tests/elementRows' +import ElementsPage from './ElementsPage' + +afterEach(cleanup) + +/** What the server says for one row (an Element has `value`, a Vault has `dValue`). */ +const nodeOf = (row: ElementListRow) => ({ + __typename: row.isVault ? 'Vault' : 'Element', + id: row.id, + code: row.code, + name: row.name, + description: row.description, + imageUrl: row.imageUrl, + elementGroup: { + __typename: 'ElementGroup', + name: row.elementGroupName, + number: row.elementGroupNumber, + apparatus: { __typename: 'Apparatus', name: row.apparatusName }, + }, + ...(row.isVault ? { dValue: row.value } : { value: row.value }), + variants: row.hasVariants + ? [{ __typename: 'Element', id: `${row.id}-x` }] + : [], +}) + +/** What the server says to `SearchCatalog`. */ +const searchAnswer = ( + rows: ElementListRow[], + totalCount = rows.length, + hasNextPage = false, +): Answer => + ok({ + searchCatalog: { + __typename: 'CatalogConnection', + totalCount, + pageInfo: { + __typename: 'PageInfo', + hasNextPage, + endCursor: rows.length ? rows[rows.length - 1].id : null, + }, + edges: rows.map((row) => ({ + __typename: 'CatalogEdge', + cursor: row.id, + node: nodeOf(row), + })), + }, + }) + +/** The server has seven rows, and finds only those with the search text in them. */ +const serverWithRows: Handler = ({ variables }) => { + const text = String(variables['search'] ?? '').toLowerCase() + const found = exampleElementRows.filter((row) => + `${row.code} ${row.name ?? ''} ${row.description}` + .toLowerCase() + .includes(text), + ) + return searchAnswer(found, text ? found.length : exampleTotalCount) +} + +/** + * A server that has the seven rows in pages of `size`: the first page, and then the + * page after the row that `after` is the cursor of (the id of a row). + */ +const pagedServer = + (size: number): Handler => + ({ variables }) => { + const after = variables['after'] + const start = after + ? exampleElementRows.findIndex((row) => row.id === after) + 1 + : 0 + const page = exampleElementRows.slice(start, start + size) + return searchAnswer( + page, + exampleElementRows.length, + start + size < exampleElementRows.length, + ) + } + +/** Shows the address, so a test can see that the search is in it. */ +function Address() { + const location = useLocation() + return ( + {location.pathname + location.search} + ) +} + +/** A button that goes to another address, like the back button does. */ +function GoTo({ to }: { to: string }) { + const navigate = useNavigate() + return ( + + ) +} + +function renderPage(handler: Handler, address = '/elements', extra?: string) { + const { client, calls } = createTestClient(handler) + render( + + + +
+ {extra && } + + , + ) + return { calls } +} + +const field = () => screen.getByRole('searchbox') +const address = () => screen.getByTestId('address').textContent +const wait = { timeout: 3000 } + +describe('ElementsPage: the page', () => { + it('has one h1, and the results under an h2 that names the section', async () => { + renderPage(serverWithRows) + + expect(screen.getAllByRole('heading', { level: 1 })).toHaveLength(1) + expect(screen.getByRole('region', { name: 'Results' })).toBeTruthy() + expect( + screen.getByRole('heading', { level: 2, name: 'Results' }), + ).toBeTruthy() + await screen.findByText(/Showing/) + }) + + it('has a labelled search field in a search landmark, that takes at most 80 characters', async () => { + renderPage(serverWithRows) + + expect(screen.getByRole('search')).toBeTruthy() + expect( + screen.getByLabelText('Search elements by code, name or description'), + ).toBe(field()) + expect(field().maxLength).toBe(MAX_SEARCH_LENGTH) + await screen.findByText(/Showing/) + }) + + it('has the filters in a labelled group, and a sort button that says what it sorts', async () => { + renderPage(serverWithRows) + await screen.findByText(/Showing/) + + const filters = screen.getByRole('group', { name: 'Filters' }) + for (const name of ['Apparatus', 'Element group', 'Difficulty']) { + expect(within(filters).getByRole('button', { name })).toBeTruthy() + } + expect( + screen.getByRole('button', { name: /Sort by Code\W+Ascending/ }), + ).toBeTruthy() + }) + + it('can reach every control with the keyboard, and each one shows where the focus is', async () => { + renderPage(pagedServer(4)) + await screen.findByText(/Showing/) + + const controls = [ + ...screen.getAllByRole('button'), + ...screen.getAllByRole('link'), + field(), + ] + // The search field, the three filters, the sort button, "Load more" (there is a next + // page), and each row twice: in the table and in a card (CSS shows only one of + // them, and what is not shown cannot be reached with Tab). + expect(controls).toHaveLength(1 + 3 + 1 + 1 + 2 * 4) + for (const control of controls) { + expect(control.getAttribute('tabindex')).not.toBe('-1') + expect(control.hasAttribute('disabled')).toBe(false) + } + for (const button of screen.getAllByRole('button')) { + // outline-none on the base button hides the ring unless the style is set again. + expect(button.className).toContain('focus-visible:outline-solid') + expect(button.className).toContain('hover:bg-(--button-hover-bg)') + // The button of the design system turns its text to --foreground (almost white in + // dark mode) on hover, which cannot be read on the tan button. + expect(button.className).toContain('hover:text-(--button-text)') + expect(button.className).toContain('dark:hover:text-(--button-text)') + } + }) + + it('says so in a status when a filter or 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/ }), + ) + expect(status.textContent).toBe('Sorting is not connected yet.') + }) +}) + +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) + + expect( + await screen.findByText( + `Showing ${exampleElementRows.length} of ${exampleTotalCount}`, + ), + ).toBeTruthy() + expect(calls).toHaveLength(1) + expect(calls[0]).toMatchObject({ + operationName: 'SearchCatalog', + variables: { search: null, first: PAGE_SIZE }, + }) + // Each row is in the table and in a card. + expect(screen.getAllByText(exampleElementRows[1].description)).toHaveLength( + 2, + ) + expect(screen.getAllByText('Yurchenko').length).toBeGreaterThan(0) + }) + + it('says 1000+ and not the number when the server counts more than 1000 (it stops at 1001)', async () => { + renderPage(() => searchAnswer(exampleElementRows, 1001, true)) + + expect( + await screen.findByText(`Showing ${exampleElementRows.length} of 1000+`), + ).toBeTruthy() + expect(screen.queryByText(/1001/)).toBeNull() + }) + + it('says the number as it is up to 1000', async () => { + renderPage(() => searchAnswer(exampleElementRows, 1000, true)) + + expect( + await screen.findByText(`Showing ${exampleElementRows.length} of 1000`), + ).toBeTruthy() + }) + + it('shows the fields of the API in each row: the group number of a vault, the value, the drawing and the variants', async () => { + renderPage(serverWithRows) + await screen.findByText(/Showing/) + + const vault = exampleElementRows.find((row) => row.isVault)! + const withVariants = exampleElementRows.find((row) => row.hasVariants)! + // The group of a vault is its number, and not the long title of the group. + expect( + screen.getAllByText(`Group ${vault.elementGroupNumber}`).length, + ).toBeGreaterThan(0) + expect(screen.queryByText(vault.elementGroupName)).toBeNull() + // The drawing is what the API says (imageUrl). + const images = screen.getAllByRole('img') + expect(images.map((image) => image.src)).toContain(vault.imageUrl) + // "More variants" only where the API lists variants (a card and a row). + expect(withVariants.hasVariants).toBe(true) + expect(screen.getAllByText('More variants')).toHaveLength(2) + }) + + it('says that the elements are loading, and then shows them', async () => { + const answer = deferred() + renderPage(() => answer.promise) + + expect( + (await screen.findByText('Loading elements…')).getAttribute('role'), + ).toBe('status') + expect( + screen.getByRole('region', { name: 'Results' }).getAttribute('aria-busy'), + ).toBe('true') + + answer.resolve(searchAnswer(exampleElementRows)) + expect(await screen.findByText(/Showing/)).toBeTruthy() + expect(screen.queryByText('Loading elements…')).toBeNull() + }) + + it('says so when nothing is found, in a status a screen reader reads, with what was searched for', async () => { + renderPage(serverWithRows) + await screen.findByText(/Showing/) + + fireEvent.change(field(), { target: { value: 'zzz' } }) + const empty = await screen.findByText('No elements match “zzz”.', {}, wait) + + expect(empty.getAttribute('role')).toBe('status') + expect(screen.queryByText(/Showing/)).toBeNull() + }) + + it('shows the error with "Try again" when the server fails, and loads again when the user tries again', async () => { + let attempt = 0 + const { calls } = renderPage(() => + ++attempt === 1 + ? failure('INTERNAL_SERVER_ERROR', 'Something broke') + : searchAnswer(exampleElementRows), + ) + + const alert = await screen.findByRole('alert') + expect(alert.textContent).toBe( + 'Something went wrong on our side. Try again.', + ) + fireEvent.click(screen.getByRole('button', { name: 'Try again' })) + + expect(await screen.findByText(/Showing/)).toBeTruthy() + expect(screen.queryByRole('alert')).toBeNull() + expect(calls).toHaveLength(2) + }) + + it('shows what the server says when the search text is not allowed, without "Try again"', async () => { + renderPage(() => failure('BAD_USER_INPUT', 'Use at most 8 words.')) + + expect((await screen.findByRole('alert')).textContent).toBe( + 'Use at most 8 words.', + ) + expect(screen.queryByRole('button', { name: 'Try again' })).toBeNull() + }) +}) + +describe('ElementsPage: the search', () => { + it('sends the text when the user has stopped typing, and not for each letter', async () => { + const { calls } = renderPage(serverWithRows) + await screen.findByText(/Showing/) + + for (const text of ['y', 'yu', 'yur', 'yurchenko']) { + fireEvent.change(field(), { target: { value: text } }) + } + + await waitFor(() => expect(calls).toHaveLength(2), wait) + expect(calls[1].variables).toMatchObject({ + search: 'yurchenko', + first: PAGE_SIZE, + }) + expect(await screen.findByText('Showing 1 of 1', {}, wait)).toBeTruthy() + }) + + it('puts the search in the address, so a result can be shared, and takes it out when the field is empty', async () => { + renderPage(serverWithRows) + await screen.findByText(/Showing/) + expect(address()).toBe('/elements') + + fireEvent.change(field(), { target: { value: ' Yurchenko ' } }) + await waitFor( + () => expect(address()).toBe('/elements?search=Yurchenko'), + wait, + ) + + fireEvent.change(field(), { target: { value: '' } }) + await waitFor(() => expect(address()).toBe('/elements'), wait) + expect( + await screen.findByText( + `Showing ${exampleElementRows.length} of ${exampleTotalCount}`, + {}, + wait, + ), + ).toBeTruthy() + }) + + it('starts with the search of the address, in the field and in the request', async () => { + const { calls } = renderPage(serverWithRows, '/elements?search=amanar') + + expect(field().value).toBe('amanar') + expect(await screen.findByText('Showing 1 of 1')).toBeTruthy() + expect(calls[0].variables).toMatchObject({ search: 'amanar' }) + }) + + it('searches at once when the user presses Enter, and does not wait', async () => { + const { calls } = renderPage(serverWithRows) + await screen.findByText(/Showing/) + + fireEvent.change(field(), { target: { value: 'biles' } }) + fireEvent.submit(screen.getByRole('search')) + + expect(address()).toBe('/elements?search=biles') + await waitFor(() => expect(calls).toHaveLength(2)) + expect(calls[1].variables).toMatchObject({ search: 'biles' }) + }) + + it('keeps the old results while a new search is on its way', async () => { + const second = deferred() + renderPage(({ variables }) => + variables['search'] ? second.promise : searchAnswer(exampleElementRows), + ) + await screen.findByText(/Showing/) + + fireEvent.change(field(), { target: { value: 'biles' } }) + fireEvent.submit(screen.getByRole('search')) + + // The new answer has not come, and the old results are still there. + await waitFor(() => expect(address()).toBe('/elements?search=biles')) + expect( + screen.getByText( + `Showing ${exampleElementRows.length} of ${exampleElementRows.length}`, + ), + ).toBeTruthy() + expect(screen.queryByText('Loading elements…')).toBeNull() + + second.resolve(searchAnswer([exampleElementRows[5]])) + expect(await screen.findByText('Showing 1 of 1')).toBeTruthy() + }) + + it('follows the address when the user goes back or forward: the field and the results change', async () => { + const { calls } = renderPage( + serverWithRows, + '/elements', + '/elements?search=amanar', + ) + await screen.findByText(/Showing/) + expect(field().value).toBe('') + + fireEvent.click( + screen.getByRole('button', { name: 'go to /elements?search=amanar' }), + ) + + await waitFor(() => expect(field().value).toBe('amanar')) + expect(await screen.findByText('Showing 1 of 1', {}, wait)).toBeTruthy() + expect(calls.at(-1)?.variables).toMatchObject({ search: 'amanar' }) + }) +}) + +describe('ElementsPage: Load more elements', () => { + const more = () => + screen.getByRole('button', { + name: /Load more|Loading more/, + }) + const tableRows = () => document.querySelectorAll('tbody tr') + + it('fetches the next page with the cursor of the last row and adds it below, and the count follows', async () => { + const { calls } = renderPage(pagedServer(4)) + await screen.findByText('Showing 4 of 7') + expect(tableRows()).toHaveLength(4) + expect(more().disabled).toBe(false) + + fireEvent.click(more()) + + expect(await screen.findByText('Showing 7 of 7')).toBeTruthy() + expect(tableRows()).toHaveLength(7) + // The next page comes in the order of the server, after the first one. + const codes = [...tableRows()].map( + (row) => row.querySelector('td')?.textContent, + ) + expect(codes).toEqual(exampleElementRows.map((row) => row.code)) + expect(calls).toHaveLength(2) + expect(calls[1]).toMatchObject({ + operationName: 'SearchCatalogMore', + variables: { + search: null, + first: PAGE_SIZE, + after: exampleElementRows[3].id, + }, + }) + }) + + it('does not ask for the count again with the next page', () => { + expect(print(SEARCH_CATALOG_QUERY)).toContain('totalCount') + expect(print(SEARCH_CATALOG_MORE_QUERY)).not.toContain('totalCount') + }) + + it('is disabled and says so while the next page is on its way, so a double click fetches it once', async () => { + const next = deferred() + const { calls } = renderPage((operation) => + operation.variables['after'] ? next.promise : pagedServer(4)(operation), + ) + await screen.findByText('Showing 4 of 7') + + fireEvent.click(more()) + await waitFor(() => expect(more().disabled).toBe(true)) + expect(more().textContent).toBe('Loading more…') + expect(more().getAttribute('aria-busy')).toBe('true') + fireEvent.click(more()) + + next.resolve(searchAnswer(exampleElementRows.slice(4), 7)) + expect(await screen.findByText('Showing 7 of 7')).toBeTruthy() + expect(calls).toHaveLength(2) + }) + + it('is disabled when everything is shown (N of N), and the count is what describes it', async () => { + renderPage(pagedServer(10)) + const count = await screen.findByText('Showing 7 of 7') + + expect(more().disabled).toBe(true) + expect(more().getAttribute('aria-describedby')).toBe(count.id) + expect(count.id).not.toBe('') + }) + + it('becomes disabled when the last page has come', async () => { + renderPage(pagedServer(4)) + await screen.findByText('Showing 4 of 7') + + fireEvent.click(more()) + + await screen.findByText('Showing 7 of 7') + expect(more().disabled).toBe(true) + expect(more().textContent).toBe('Load more elements') + }) + + it('shows the error, keeps what is shown, and lets the user press the button again', async () => { + let attempt = 0 + const { calls } = renderPage((operation) => { + if (operation.variables['after'] && ++attempt === 1) { + return failure('INTERNAL_SERVER_ERROR', 'Something broke') + } + return pagedServer(4)(operation) + }) + await screen.findByText('Showing 4 of 7') + + fireEvent.click(more()) + + const alert = await screen.findByRole('alert') + expect(alert.textContent).toBe( + 'Something went wrong on our side. Try again. Press the button to try again.', + ) + expect(tableRows()).toHaveLength(4) + expect(more().disabled).toBe(false) + + fireEvent.click(more()) + + expect(await screen.findByText('Showing 7 of 7')).toBeTruthy() + expect(screen.queryByRole('alert')).toBeNull() + expect(calls).toHaveLength(3) + }) + + it('starts again with the first page when the search changes, and the error of the old search goes', async () => { + const { calls } = renderPage((operation) => + operation.variables['after'] && !operation.variables['search'] + ? failure('INTERNAL_SERVER_ERROR', 'Something broke') + : serverWithRowsPaged(operation), + ) + await screen.findByText('Showing 4 of 7') + fireEvent.click(more()) + await screen.findByRole('alert') + + fireEvent.change(field(), { target: { value: 'yurchenko' } }) + fireEvent.submit(screen.getByRole('search')) + + expect(await screen.findByText('Showing 1 of 1', {}, wait)).toBeTruthy() + expect(screen.queryByRole('alert')).toBeNull() + const last = calls.at(-1) + expect(last?.operationName).toBe('SearchCatalog') + expect(last?.variables).toMatchObject({ search: 'yurchenko' }) + expect(last?.variables['after']).toBeUndefined() + }) +}) + +/** A server that pages the seven rows, and also finds rows by their text. */ +function serverWithRowsPaged(operation: Parameters[0]) { + const text = String(operation.variables['search'] ?? '').toLowerCase() + if (!text) return pagedServer(4)(operation) + const found = exampleElementRows.filter((row) => + `${row.code} ${row.name ?? ''} ${row.description}` + .toLowerCase() + .includes(text), + ) + return searchAnswer(found, found.length) +} diff --git a/src/components/elements/ElementsPage.tsx b/src/components/elements/ElementsPage.tsx new file mode 100644 index 0000000..e84e201 --- /dev/null +++ b/src/components/elements/ElementsPage.tsx @@ -0,0 +1,250 @@ +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 ResultsTable from '@/components/elements/ResultsTable' +import { Button } from '@/components/ui/button' +import { describeError } from '@/lib/apollo/errors' +import { + formatTotal, + MAX_SEARCH_LENGTH, + PAGE_SIZE, + SEARCH_CATALOG_MORE_QUERY, + SEARCH_CATALOG_QUERY, + SEARCH_DELAY_MS, + toListRow, +} from '@/lib/searchOperations' + +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. +export default function ElementsPage() { + const [status, setStatus] = useState('') + const [searchParams, setSearchParams] = useSearchParams() + + // The search is in the address (?search=...), so a result can be shared and the + // back button works. `text` is what is in the field, and `search` is what the + // address says (what the search uses): the address follows the field when the user + // has stopped typing. + const search = (searchParams.get('search') ?? '').trim() + const [text, setText] = useState(searchParams.get('search') ?? '') + const written = useRef(search) + + function writeAddress(value: string) { + const trimmed = value.trim() + if (trimmed === written.current) return + written.current = trimmed + setSearchParams(trimmed ? { search: trimmed } : {}, { replace: true }) + } + + useEffect(() => { + const timer = setTimeout(() => writeAddress(text), SEARCH_DELAY_MS) + return () => clearTimeout(timer) + // writeAddress only uses what is in the dependencies and the ref. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [text]) + + // The user went back or forward to another address: the field follows it. + useEffect(() => { + if (search !== written.current) { + written.current = search + setText(search) + } + }, [search]) + + const { data, previousData, loading, error, refetch, fetchMore } = useQuery( + SEARCH_CATALOG_QUERY, + { variables: { search: search || null, 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 + const rows = shown?.edges.map((edge) => toListRow(edge.node)) ?? [] + const hasNextPage = shown?.pageInfo.hasNextPage ?? false + + // "Load more": the next page is fetched with the cursor of the last one, and the + // cache adds it to the pages that are shown (src/lib/apollo/cache.ts). An error is + // for the search it happened in, and goes away with the next search. + const [loadingMore, setLoadingMore] = useState(false) + const [moreError, setMoreError] = useState<{ + search: string + message: string + } | null>(null) + + async function loadMore() { + const after = shown?.pageInfo.endCursor + if (!after || loadingMore) return + setLoadingMore(true) + setMoreError(null) + try { + await fetchMore({ + query: SEARCH_CATALOG_MORE_QUERY, + variables: { search: search || null, first: PAGE_SIZE, after }, + }) + } catch (problem) { + setMoreError({ search, message: describeError(problem).message }) + } finally { + setLoadingMore(false) + } + } + + function submit(event: FormEvent) { + event.preventDefault() + writeAddress(text) + } + + return ( +
+

Elements

+ +
+ +
+ ) +} diff --git a/src/components/elements/ResultsTable.test.tsx b/src/components/elements/ResultsTable.test.tsx new file mode 100644 index 0000000..ecab212 --- /dev/null +++ b/src/components/elements/ResultsTable.test.tsx @@ -0,0 +1,302 @@ +import { renderToString } from 'react-dom/server' +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' + +const render = (rows: ElementListRow[]) => + renderToString( + + + , + ) + +/** The cards (a phone) and the table (a wide screen) are both in the page. */ +const cardsOf = (markup: string) => + /]*md:hidden[^>]*>.*<\/ul>/s.exec(markup)?.[0] ?? '' +const tableOf = (markup: string) => //s.exec(markup)?.[0] ?? '' + +const withVariants = exampleElementRows.find((row) => row.hasVariants)! +const plain = exampleElementRows.find((row) => row.id === 'VT-1.22-a')! +const named = exampleElementRows.find((row) => row.name)! +const element = exampleElementRows.find((row) => !row.isVault)! + +describe('ResultsTable: the table (a wide screen)', () => { + it('is a table with a caption and the five column headings', () => { + const table = tableOf(render(exampleElementRows)) + + expect(table).toMatch(/]*>[^<]*Elements and vaults/) + for (const heading of [ + 'Code', + 'Element', + 'Apparatus', + 'Element group', + 'Difficulty', + ]) { + expect(table).toMatch( + new RegExp( + `]*scope="col"[^>]*>(]*>)?(]*>)?${heading}()?(
)?`, + ), + ) + } + }) + + it('is not shown on a phone, where the cards are', () => { + const markup = render(exampleElementRows) + + expect(markup).toMatch(/
{ + const table = tableOf(render(exampleElementRows)) + + expect( + table.match( + /]*role="row"[^>]*class="group relative cursor-pointer/g, + ), + ).toHaveLength(exampleElementRows.length) + expect(table).toContain(`href="/elements/${plain.id}"`) + expect(table).toContain(plain.description) + }) + + it('answers on the whole row, by getting taller and not wider, and not by underlining the text', () => { + const table = tableOf(render(exampleElementRows)) + + expect(table).toContain('hover:*:shadow-[0_-5px_0_0_var(--row-hover)') + expect(table).toContain( + 'focus-within:*:shadow-[0_-5px_0_0_var(--row-hover)', + ) + // The text only gets a very thin shadow in its own color (a little bolder). More + // than that makes it harder to read. + expect(table).toContain('hover:[text-shadow:0.15px_0_0_currentColor,') + expect(table).toContain( + 'focus-within:[text-shadow:0.15px_0_0_currentColor,', + ) + expect(table).not.toContain('0.35px') + expect(table).not.toContain('hover:font-') + expect(table).not.toContain('scale-') + expect(table).not.toContain('underline') + }) + + it('has a chevron in each row that only appears on hover and focus, and is hidden from a screen reader', () => { + const table = tableOf(render(exampleElementRows)) + + const chevrons = table.match(/]*lucide-chevron-right[^>]*>/g) ?? [] + + expect(chevrons).toHaveLength(exampleElementRows.length) + for (const chevron of chevrons) { + expect(chevron).toContain('aria-hidden="true"') + expect(chevron).toContain('group-hover:opacity-100') + expect(chevron).toContain('group-focus-within:opacity-100') + } + }) + + it('puts the chevron next to the badge, not on top of it, and the heading over the badge', () => { + const table = tableOf(render([plain])) + + // The [ and ] of the class are escaped, as they mean something in a regex. + const layout = 'grid grid-cols-[1fr_auto_1fr] items-center'.replace( + /[[\]]/g, + '\\$&', + ) + // The badge is in the middle column and the chevron in the one to the right. + expect(table).toMatch( + new RegExp( + `
.*Difficulty
`, + ), + ) + expect(table).not.toContain('absolute top-1/2 right-2') + }) + + it('has the apparatus and the group in two columns of their own, not in one cell', () => { + const table = tableOf(render([plain])) + + expect(table).toMatch( + new RegExp( + `]*>${plain.apparatusName}]*>${groupLabel(plain)}`, + ), + ) + }) + + it('shows a vault by the number of its group, and not by the long title of the group', () => { + const table = tableOf(render([plain, element])) + + expect(plain.elementGroupName.length).toBeGreaterThan(40) + expect(table).toContain(`>Group ${plain.elementGroupNumber}`) + expect(table).not.toContain(plain.elementGroupName) + // An element is shown by the name of its group, which is short. + expect(table).toContain(`>${element.elementGroupName}`) + }) + + it('shows the code in bold', () => { + expect(tableOf(render([plain]))).toMatch( + new RegExp(`]*font-bold[^>]*>${plain.code}`), + ) + }) + + it('shows the name of the gymnast above the description', () => { + const table = tableOf(render([named])) + + // The description has characters (the brackets) that mean something in a regex, so + // it is not put in one. + const afterName = table.slice(table.indexOf(`${named.name}`)) + expect(afterName).toMatch(/^[^<]*<\/span>]*>/) + expect(afterName).toContain(`>${named.description}`) + expect(tableOf(render([plain]))).not.toContain('font-heading') + }) + + it('says "More variants" in the row only on a row that has variants', () => { + expect(tableOf(render([withVariants]))).toContain('More variants') + expect(tableOf(render([plain, named]))).not.toContain('More variants') + }) + + it('uses the difficulty badge: the difficulty of a vault, and the letter and value of an element', () => { + const vault = tableOf(render([plain])) + expect(vault).toContain('Difficulty 2.6') + + const letter = tableOf(render([element])) + expect(letter).toContain('Difficulty A') + expect(letter).toContain('Value 0.1') + }) +}) + +describe('ResultsTable: the cards (a phone and a small screen)', () => { + it('are a list of cards below each other, only on a small screen, and without a table heading', () => { + const markup = render(exampleElementRows) + const cards = cardsOf(markup) + + expect(cards).toMatch(/^
    { + const cards = cardsOf(render(exampleElementRows)) + + expect(cards).toContain('grid-cols-1') + expect(cards).toContain('min-[600px]:grid-cols-2') + }) + + it('have the title first: the code, and the name if there is one', () => { + const plainCard = cardsOf(render([plain])) + const namedCard = cardsOf(render([named])) + + expect(plainCard).toMatch( + new RegExp( + `]*>]*href="/elements/${plain.id}"[^>]*>${plain.code}`, + ), + ) + expect(namedCard).toMatch( + new RegExp( + `]*>]*>${named.code}]*>${named.name}`, + ), + ) + }) + + it('have the description under the title, then the rows downward (apparatus, group, value with its badge), then the drawing below', () => { + const card = cardsOf(render([plain])) + + const order = [ + 'Apparatus:', + `>${plain.apparatusName}`, + '>Element group:', + `>${groupLabel(plain)}`, + '>Difficulty:', + 'Difficulty ', + ' card.indexOf(part)) + + expect(order.every((position) => position >= 0)).toBe(true) + expect([...order].sort((a, b) => a - b)).toEqual(order) + }) + + it('show a vault by the number of its group, and not by the long title of the group', () => { + const card = cardsOf(render([plain])) + + expect(card).toContain(`>Group ${plain.elementGroupNumber}`) + expect(card).not.toContain(plain.elementGroupName) + }) + + it('show the drawing below the text, always, with a text for a screen reader, loaded when it is near', () => { + const plainCard = cardsOf(render([plain])) + const namedCard = cardsOf(render([named])) + + const image = /]*>/.exec(plainCard)?.[0] ?? '' + expect(image).toContain(`src="${plain.imageUrl}"`) + expect(image).toContain('loading="lazy"') + expect(image.split(/[ "]/)).not.toContain('hidden') + expect(plainCard).toContain(`alt="Illustration for ${plain.code}"`) + expect(namedCard).toContain( + `alt="Illustration for ${named.code}: ${named.name}"`, + ) + // Not beside the text: there is no column for it. + expect(plainCard).not.toContain('@container') + }) + + it('call the difficulty "Difficulty" in every card, and the group "Element group", each as the label of its value', () => { + for (const row of [element, plain]) { + const card = cardsOf(render([row])) + + expect(card).toContain('>Difficulty:') + expect(card).not.toContain('>Value:') + expect(card).not.toContain('>D-value:') + expect(card).toContain('>Element group:') + // A label (dt) and its value (dd) are in one description list, so they are + // connected for a screen reader. + expect(card).toMatch( + /]*>.*>Element group:<\/dt>]*>[^<]*<\/dd>.*<\/dl>/s, + ) + } + }) + + it('link as a whole card to the element page, and show where the focus is', () => { + const card = cardsOf(render([plain])) + + expect(card).toContain(`href="/elements/${plain.id}"`) + expect(card).toContain('after:absolute') + expect(card).toContain('focus-within:outline-2') + expect(card).toContain('focus-within:outline-solid') + }) + + it('answer on hover, to show that they can be pressed: they move up a little and get a stronger shadow', () => { + const card = cardsOf(render([plain])) + + expect(card).toContain('cursor-pointer') + expect(card).toContain('motion-safe:hover:-translate-y-0.5') + expect(card).toContain('hover:shadow-[') + // In dark mode the card is also a little lighter, as the rows of the table are + // (the amount is 0 % in light mode, where nothing changes color on hover). + expect(card).toContain('var(--row-hover-amount)') + expect(card).toContain('hover:bg-(--card-hover)') + }) + + it('have an arrow like the rows of the table: in on hover and focus, always there without hover, and hidden from a screen reader', () => { + const cards = cardsOf(render(exampleElementRows)) + + const arrows = cards.match(/]*lucide-chevron-right[^>]*>/g) ?? [] + expect(arrows).toHaveLength(exampleElementRows.length) + for (const arrow of arrows) { + expect(arrow).toContain('aria-hidden="true"') + expect(arrow).toContain('group-hover:opacity-100') + expect(arrow).toContain('group-focus-within:opacity-100') + expect(arrow).toContain('[@media(hover:none)]:opacity-100') + } + }) + + it('say "More variants" only on a card that has variants', () => { + const card = cardsOf(render([withVariants])) + + expect(card).toContain('More variants') + expect(cardsOf(render([plain, named]))).not.toContain('More variants') + }) +}) diff --git a/src/components/elements/ResultsTable.tsx b/src/components/elements/ResultsTable.tsx new file mode 100644 index 0000000..ab91b33 --- /dev/null +++ b/src/components/elements/ResultsTable.tsx @@ -0,0 +1,250 @@ +import { ChevronRight } from 'lucide-react' +import { Link } from 'react-router-dom' +import DifficultyBadge from '@/components/ui/DifficultyBadge' +import { getDifficultyLetter } from '@/lib/difficulty' +import { groupLabel, type ElementListRow } from '@/lib/elementList' + +// 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 +// columns do not fit. Both are always in the page, and CSS shows one of them, so a +// screen reader gets each result once. + +/** The letter of an element, or the difficulty of a vault, for the badge. */ +const badgeValue = (row: ElementListRow) => + (!row.isVault && getDifficultyLetter(row.value)) || row.value + +const MORE_VARIANTS = + 'rounded-full bg-(--pill-bg) px-3 py-1 text-xs font-bold whitespace-nowrap text-(--pill-text)' + +// --------------------------------------------------------------------------- +// The cards (a phone and a small screen) +// --------------------------------------------------------------------------- + +/** + * A card is a small table with the rows downward: the title (the code, and the + * name if the element has one), the description, the apparatus, the group and the + * value with its badge, and the drawing below. The whole card is one link (the + * title). The cards are in two columns when the screen is wide enough for it. + * + * To show that a card can be pressed it moves up a little and gets a stronger + * shadow on hover (in dark mode it is also a little lighter, as the rows of the + * table are), and it has an arrow, like the rows of the table. The arrow comes in + * on hover and focus, and is always there on a screen without hover (a phone), where + * nothing else would show it. + */ +function ResultCards({ rows }: { rows: ElementListRow[] }) { + return ( +
      + {rows.map((row) => ( +
    • +
      +

      + {/* The link covers the whole card (the ::after). */} + + {row.code} + {row.name && ( + <> + + + {row.name} + + + )} + +

      +
      +

      {row.description}

      +
      +
      Apparatus:
      +
      {row.apparatusName}
      +
      Element group:
      +
      {groupLabel(row)}
      +
      Difficulty:
      +
      + +
      +
      + {row.hasVariants && ( + + More variants + + )} + {/* The drawing is below the text. If it is missing, nothing is shown, + and not a broken image. */} + {`Illustration { + event.currentTarget.hidden = true + }} + className="mt-4 block h-auto max-h-56 w-full rounded-md bg-white object-contain" + /> +
    • + ))} +
    + ) +} + +// --------------------------------------------------------------------------- +// The table (a wide screen) +// --------------------------------------------------------------------------- + +const CELL = 'align-middle px-4 py-3' +// The badge is in the middle of the cell, and the chevron has the column to the +// right of it, so it never ends up behind the badge, and the heading above can +// use the same columns to be in the middle over the badge. +const VALUE_LAYOUT = 'grid grid-cols-[1fr_auto_1fr] items-center' +const COLUMN_HEADER = + 'bg-(--table-head-bg) px-4 py-3 text-left text-[0.8125rem] font-bold uppercase tracking-wide text-(--table-head-text) first:rounded-tl-lg last:rounded-tr-lg' + +// The whole row is one link, so the whole row answers when the pointer or the +// keyboard is on it: it gets taller (5 px above and below, in front of the next +// rows) and, in dark mode, a little lighter. Only the height changes, so nothing +// moves and the table does not get wider. A shadow does it. The text looks only +// a little bolder (a very thin shadow in the color of the text): real bold makes +// the words wider and moves the line breaks, and more than a little makes the +// text harder to read. (The classes are written out, so Tailwind can find them.) +const ROW_EFFECT = + 'group relative cursor-pointer [--row-bg:var(--surface)] even:[--row-bg:var(--table-row-alt)] [--row-hover:color-mix(in_srgb,var(--row-bg),var(--text-color)_var(--row-hover-amount))] hover:*:bg-(--row-hover) focus-within:*:bg-(--row-hover) not-last:hover:bg-(--row-hover) not-last:focus-within:bg-(--row-hover) hover:z-10 focus-within:z-10 hover:*:shadow-[0_-5px_0_0_var(--row-hover),0_5px_0_0_var(--row-hover)] focus-within:*:shadow-[0_-5px_0_0_var(--row-hover),0_5px_0_0_var(--row-hover)] hover:[text-shadow:0.15px_0_0_currentColor,-0.15px_0_0_currentColor] focus-within:[text-shadow:0.15px_0_0_currentColor,-0.15px_0_0_currentColor] *:transition-[box-shadow,background-color] *:duration-150 motion-reduce:*:transition-none focus-within:outline-2 focus-within:-outline-offset-2 focus-within:outline-(--text-color)' + +// The colors are on the cells (so the corners can be round). +const ROW_LOOK = + 'not-last:bg-(--surface) not-last:even:bg-(--table-row-alt) *:bg-(--surface) even:*:bg-(--table-row-alt) last:*:first:rounded-bl-lg last:*:last:rounded-br-lg' + +function ResultTableView({ rows }: { rows: ElementListRow[] }) { + return ( +
    +
+ + + + + + + + + + + + {rows.map((row) => ( + + + + + + + + ))} + +
+ Elements and vaults in the Code of Points, one row for each code +
+ Code + + Element + + Apparatus + + Element group + + {/* The same three columns as the cell, so the heading is exactly + over the badge. */} +
+ Difficulty +
+
+ {row.code} + +
+ {/* The link covers the whole row (the ::after), so a click + anywhere in the row opens the element. */} + + {row.name && ( + + {row.name} + + )} + + {row.description} + + + {row.hasVariants && ( + More variants + )} +
+
+ {row.apparatusName} + + {groupLabel(row)} + +
+ {/* The letter of an element, or the difficulty of a vault. */} + + + + {/* Shown on hover and focus, with its own place so nothing + moves when it shows. */} +
+
+
+ ) +} + +export default function ResultsTable({ rows }: { rows: ElementListRow[] }) { + return ( + <> + + + + ) +} diff --git a/src/components/gymnasts/YourGymnastsPage.tsx b/src/components/gymnasts/YourGymnastsPage.tsx index 8787c32..2e99f2f 100644 --- a/src/components/gymnasts/YourGymnastsPage.tsx +++ b/src/components/gymnasts/YourGymnastsPage.tsx @@ -86,8 +86,8 @@ export default function YourGymnastsPage() { } return ( -
-
+
+

{announcement}

diff --git a/src/components/navbar/Navbar.test.tsx b/src/components/navbar/Navbar.test.tsx index 595acc2..157b893 100644 --- a/src/components/navbar/Navbar.test.tsx +++ b/src/components/navbar/Navbar.test.tsx @@ -44,7 +44,7 @@ describe('Navbar', () => { 'min-[720px]:grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)]', ); expect(header.className).toContain('relative'); - expect(screen.getByRole('link', { name: 'Elements' })).toBeTruthy(); + 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(); @@ -102,10 +102,20 @@ describe('Navbar', () => { 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(); + + 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(); - const currentPageLink = screen.getByRole('link', { name: 'Elements' }); + const currentPageLink = screen.getByRole('link', { name: 'Search' }); expect(currentPageLink.className).toContain('after:scale-x-100'); expect(screen.getByRole('navigation').className).not.toContain('pb-[6px]'); }); diff --git a/src/components/navbar/Navbar.tsx b/src/components/navbar/Navbar.tsx index caea231..a413049 100644 --- a/src/components/navbar/Navbar.tsx +++ b/src/components/navbar/Navbar.tsx @@ -19,7 +19,7 @@ export const Navbar: React.FC = ({ const focusRing = 'focus-visible:outline-2 focus-visible:outline-(--text-color) focus-visible:outline-offset-4'; const navLinks = [ - { path: '/', label: 'Elements' }, + { path: '/elements', label: 'Search' }, { path: '/calculator', label: 'Calculator' }, { path: '/about', label: 'About' }, ...(isLoggedIn ? [{ path: '/gymnasts', label: 'Gymnasts' }] : []), @@ -28,7 +28,7 @@ export const Navbar: React.FC = ({ return (
+ / { + it('always shows a number with one decimal', () => { + for (const [value, shown] of [ + [2, '2.0'], + [2.6, '2.6'], + [6.4, '6.4'], + [0.5, '0.5'], + [3.04, '3.0'], + ] as const) { + expect(renderToString()).toContain( + `Difficulty ${shown}`, + ) + } + }) + + it('has a smaller text for a number, and the same size as before for a letter', () => { + const number = circle(renderToString()) + const letter = circle(renderToString()) + + expect(number?.[1].split(' ')).toContain('text-base') + expect(number?.[1].split(' ')).not.toContain('text-lg') + expect(letter?.[1].split(' ')).toContain('text-lg') + expect(letter?.[1].split(' ')).not.toContain('text-base') + }) + + it('shows a letter as a letter, with its value under it', () => { + const markup = renderToString() + + expect(markup).toContain('Difficulty B') + expect(markup).toContain('Value 0.2') + }) +}) diff --git a/src/components/ui/DifficultyBadge.tsx b/src/components/ui/DifficultyBadge.tsx index d49d82a..cda52be 100644 --- a/src/components/ui/DifficultyBadge.tsx +++ b/src/components/ui/DifficultyBadge.tsx @@ -9,7 +9,11 @@ export default function DifficultyBadge({ const letter = typeof difficulty === 'string' ? difficulty.toUpperCase() : undefined const value = letter ? getDifficultyValue(letter) : undefined - const displayDifficulty = value !== undefined ? letter : difficulty + // A number (the difficulty of a vault) always has one decimal, so 2 is 2.0, and its text + // is a little smaller than a letter, so it fits in the circle. + const isNumber = typeof difficulty === 'number' + const displayDifficulty = + value !== undefined ? letter : isNumber ? difficulty.toFixed(1) : difficulty return ( @@ -19,7 +23,8 @@ export default function DifficultyBadge({ Difficulty diff --git a/src/components/ui/ElementVariants.tsx b/src/components/ui/ElementVariants.tsx index 63996b7..00b069c 100644 --- a/src/components/ui/ElementVariants.tsx +++ b/src/components/ui/ElementVariants.tsx @@ -51,7 +51,7 @@ export default function ElementVariants({ ) : ( )} diff --git a/src/lib/elementList.ts b/src/lib/elementList.ts new file mode 100644 index 0000000..e0142d9 --- /dev/null +++ b/src/lib/elementList.ts @@ -0,0 +1,29 @@ +/** One row of the results table on the Elements page. */ +export type ElementListRow = { + id: string + code: string + /** The gymnast the element is named after, if any. */ + name: string | null + description: string + apparatusName: string + elementGroupName: string + /** The number of the group on the apparatus (the API's `elementGroup.number`). */ + elementGroupNumber: number + /** The value of an element, or the difficulty of a vault. */ + value: number + isVault: boolean + hasVariants: boolean + /** The drawing of the element, as the API gives it (`imageUrl`). */ + imageUrl: string +} + +/** + * The group to show. A vault group has no short name: the name in the Code of + * Points is the long title of the group (for example "Round-off (Yurchenko) with/wo + * ¾ turn (270°) in 1st flight phase – salto bwd with/without twist in 2nd flight + * phase"), which is a description and does not fit as a label. A vault is + * therefore shown by the number of its group ("Group 4"), and an element by the name + * of its group ("Mounts"). + */ +export const groupLabel = (row: ElementListRow): string => + row.isVault ? `Group ${row.elementGroupNumber}` : row.elementGroupName diff --git a/src/lib/searchOperations.test.ts b/src/lib/searchOperations.test.ts new file mode 100644 index 0000000..c83da47 --- /dev/null +++ b/src/lib/searchOperations.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest' +import { + formatTotal, + MAX_COUNTED, + toListRow, + type SearchNode, +} from './searchOperations' + +const group = { + name: 'Mounts', + number: 1, + apparatus: { name: 'Uneven bars' }, +} + +const element: SearchNode = { + __typename: 'Element', + id: 'UB-1.101-a', + code: '1.101', + name: null, + description: 'Glide kip to support on LB', + imageUrl: 'http://images.test/UB/UB-1.101-a.webp', + elementGroup: group, + value: 0.1, + variants: [{ id: 'UB-1.101-b' }], +} + +const vault: SearchNode = { + __typename: 'Vault', + id: 'VT-4.35-a', + code: '4.35', + name: 'Amanar', + description: 'Round-off, flic-flac on', + imageUrl: 'http://images.test/VT/VT-4.35-a.webp', + elementGroup: { + name: 'A long title', + number: 4, + apparatus: { name: 'Vault' }, + }, + dValue: 5.4, + variants: [], +} + +describe('toListRow', () => { + it('makes a row of an element: its value, its group by name and number, and that it has variants', () => { + expect(toListRow(element)).toEqual({ + id: 'UB-1.101-a', + code: '1.101', + name: null, + description: 'Glide kip to support on LB', + apparatusName: 'Uneven bars', + elementGroupName: 'Mounts', + elementGroupNumber: 1, + value: 0.1, + isVault: false, + hasVariants: true, + imageUrl: 'http://images.test/UB/UB-1.101-a.webp', + }) + }) + + it('makes a row of a vault: its difficulty, the name of the gymnast, and no variants', () => { + expect(toListRow(vault)).toMatchObject({ + name: 'Amanar', + apparatusName: 'Vault', + elementGroupNumber: 4, + value: 5.4, + isVault: true, + hasVariants: false, + }) + }) +}) + +describe('formatTotal', () => { + it('says the number as it is up to 1000', () => { + expect(formatTotal(0)).toBe('0') + expect(formatTotal(562)).toBe('562') + expect(formatTotal(MAX_COUNTED)).toBe('1000') + }) + + it('says 1000+ for more than 1000, which is what the server gives (1001), and for anything above', () => { + expect(MAX_COUNTED).toBe(1000) + expect(formatTotal(1001)).toBe('1000+') + expect(formatTotal(250000)).toBe('1000+') + }) +}) diff --git a/src/lib/searchOperations.ts b/src/lib/searchOperations.ts new file mode 100644 index 0000000..d98f01b --- /dev/null +++ b/src/lib/searchOperations.ts @@ -0,0 +1,163 @@ +// The search of the Elements page. The types are written by hand (one query does not +// need code generation); the fields are the ones in docs/API.md ("Search, filter, +// sort and pagination"). + +import { gql, type TypedDocumentNode } from '@apollo/client' +import type { ElementListRow } from '@/lib/elementList' + +/** How many results one page has. The API allows 1-100. */ +export const PAGE_SIZE = 20 + +/** How long the user has to stop typing before the search is sent. */ +export const SEARCH_DELAY_MS = 300 + +/** The longest search text the API takes (more gives BAD_USER_INPUT). */ +export const MAX_SEARCH_LENGTH = 80 + +/** + * The most results that the server counts. A search with more matches gets one more + * (1001) as its `totalCount`, which means "more than this many" (see "Search, filter, + * sort and pagination" in docs/API.md). + */ +export const MAX_COUNTED = 1000 + +/** The number of results as the page says it: 562, or 1000+ when there are more than 1000. */ +export const formatTotal = (totalCount: number): string => + totalCount > MAX_COUNTED ? `${MAX_COUNTED}+` : String(totalCount) + +/** What the queries are sent with. `after` is the cursor of the page to load next. */ +export type SearchVariables = { + search: string | null + first: number + after?: string | null +} + +type NodeFields = { + id: string + code: string + name: string | null + description: string + imageUrl: string + elementGroup: { + name: string + number: number + apparatus: { name: string } + } +} + +/** One result: an Element has `value`, a Vault has `dValue`. */ +export type SearchNode = NodeFields & + ( + | { __typename: 'Element'; value: number; variants: { id: string }[] } + | { __typename: 'Vault'; dValue: number; variants: { id: string }[] } + ) + +type Page = { + pageInfo: { hasNextPage: boolean; endCursor: string | null } + edges: { cursor: string; node: SearchNode }[] +} + +/** The first page of a search, with the number of results in all. */ +export type SearchCatalogResult = { + searchCatalog: Page & { totalCount: number } +} + +/** A later page of a search. The count is not asked for again. */ +export type SearchCatalogMoreResult = { searchCatalog: Page } + +// The fields of one result, the same in both queries. +const RESULT = ` + id + code + name + description + imageUrl + elementGroup { + name + number + apparatus { + name + } + } + ... on Element { + value + variants { + id + } + } + ... on Vault { + dValue + variants { + id + } + } +` + +/** + * The first page of a search. It asks for `totalCount`, which the API counts only when + * it is asked (counting is expensive on a large result). + */ +export const SEARCH_CATALOG_QUERY: TypedDocumentNode< + SearchCatalogResult, + SearchVariables +> = gql` + query SearchCatalog($search: String, $first: Int!, $after: String) { + searchCatalog(search: $search, first: $first, after: $after) { + totalCount + pageInfo { + hasNextPage + endCursor + } + edges { + cursor + node { + ${RESULT} + } + } + } + } +` + +/** + * The next page of a search ("Load more"). It is the same field with `after`, so the + * cache adds it to the pages that are shown (see src/lib/apollo/cache.ts), and it + * does not ask for `totalCount` again: the cache keeps the first one. + */ +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) { + pageInfo { + hasNextPage + endCursor + } + edges { + cursor + node { + ${RESULT} + } + } + } + } +` + +/** A result of the search as a row of the table and of the cards. */ +export function toListRow(node: SearchNode): ElementListRow { + const isVault = node.__typename === 'Vault' + + return { + id: node.id, + code: node.code, + name: node.name, + description: node.description, + apparatusName: node.elementGroup.apparatus.name, + elementGroupName: node.elementGroup.name, + elementGroupNumber: node.elementGroup.number, + value: node.__typename === 'Vault' ? node.dValue : node.value, + isVault, + hasVariants: node.variants.length > 0, + imageUrl: node.imageUrl, + } +} diff --git a/src/styles/global.css b/src/styles/global.css index 4db4f32..e70f417 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -2,6 +2,68 @@ color: var(--text-color-dark); } +/* The pages use the whole width of the window, not the 1126px that #root has in + index.css. Each page has its own padding on the sides. */ +#root { + width: 100%; + border-inline: 0; +} + +#root .elements-page :is(h1, h2) { + color: var(--text-color); + font-weight: 700; + letter-spacing: -0.01em; + line-height: 1.1; + margin: 0; +} + +/* 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 { + color: var(--pill-text); +} + +/* The value under the letter in the difficulty badge is dark navy, which cannot + 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 { + 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 { + background-color: var(--pill-bg); + color: var(--pill-text); +} + +/* The X that clears the search field (Chrome, Edge and Safari draw one in a color + that cannot be seen on our field). It is drawn here in the color of the search + icon (--text-color), in both themes, with a 24 px target around a 16 px X. */ +#root .elements-page input[type='search']::-webkit-search-cancel-button { + -webkit-appearance: none; + appearance: none; + width: 1.5rem; + height: 1.5rem; + 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; +} + +#root .elements-page h1 { + font-size: 2.125rem; + font-weight: 800; +} + +#root .elements-page h2 { + font-size: 1.25rem; + margin-bottom: 0.75rem; +} + #root .your-gymnasts-page :is(h1, h2) { color: var(--text-color-dark); } diff --git a/src/styles/variables.css b/src/styles/variables.css index 7bdfe92..73e5d5c 100644 --- a/src/styles/variables.css +++ b/src/styles/variables.css @@ -26,6 +26,31 @@ --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 + 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 + 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 + "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 + 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; + /* D-value badge */ --badge-d-value-bg: #BDE981; --badge-d-value-text: var(--text-color-dark); @@ -64,4 +89,23 @@ --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 + 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); } diff --git a/src/tests/elementRows.ts b/src/tests/elementRows.ts new file mode 100644 index 0000000..93b4398 --- /dev/null +++ b/src/tests/elementRows.ts @@ -0,0 +1,110 @@ +// Seven real rows from the catalog (made from data/*.json), for tests of the Elements +// page. The ids, texts, values and group numbers exist in the database. + +import type { ElementListRow } from '@/lib/elementList' + +const rowsWithoutImage: Omit[] = [ + { + id: 'VT-1.22-a', + code: '1.22', + name: null, + description: 'Handspring fwd with ½ turn (180°) on – 1/1 turn (360°) off', + apparatusName: 'Vault', + elementGroupName: + 'Handspring, Yamashita, round-off with or without turn in 1st and/or 2nd flight phase', + elementGroupNumber: 1, + value: 2.6, + isVault: true, + hasVariants: false, + }, + { + id: 'VT-2.31-a', + code: '2.31', + name: 'Wang', + description: + 'Handspring fwd on – stretched salto fwd with ½ twist (180°) off', + apparatusName: 'Vault', + elementGroupName: + 'Handspring fwd with/without 1/1 turn (360°) in 1st flight phase – salto fwd/bwd with/without twist in 2nd flight phase', + elementGroupNumber: 2, + value: 4.6, + isVault: true, + hasVariants: false, + }, + { + id: 'VT-2.50-a', + code: '2.50', + name: 'Produnova', + description: 'Handspring fwd on – tucked double salto fwd off', + apparatusName: 'Vault', + elementGroupName: + 'Handspring fwd with/without 1/1 turn (360°) in 1st flight phase – salto fwd/bwd with/without twist in 2nd flight phase', + elementGroupNumber: 2, + value: 6, + isVault: true, + hasVariants: false, + }, + { + id: 'VT-4.10-a', + code: '4.10', + name: 'Yurchenko', + description: 'Round-off, flic-flac on – tucked salto bwd off', + apparatusName: 'Vault', + elementGroupName: + 'Round-off (Yurchenko) with/wo ¾ turn (270°) in 1st flight phase – salto bwd with/without twist in 2nd flight phase', + elementGroupNumber: 4, + value: 3, + isVault: true, + hasVariants: false, + }, + { + id: 'VT-4.35-a', + code: '4.35', + name: 'Amanar', + description: + 'Round-off, flic-flac on –stretched salto bwd with 2½ twist (900°) off', + apparatusName: 'Vault', + elementGroupName: + 'Round-off (Yurchenko) with/wo ¾ turn (270°) in 1st flight phase – salto bwd with/without twist in 2nd flight phase', + elementGroupNumber: 4, + value: 5.4, + isVault: true, + hasVariants: false, + }, + { + id: 'VT-4.62-a', + code: '4.62', + name: 'Biles', + description: 'Round-off, flic-flac on –double piked salto bwd off', + apparatusName: 'Vault', + elementGroupName: + 'Round-off (Yurchenko) with/wo ¾ turn (270°) in 1st flight phase – salto bwd with/without twist in 2nd flight phase', + elementGroupNumber: 4, + value: 6.4, + isVault: true, + hasVariants: false, + }, + { + id: 'UB-1.101-a', + code: '1.101', + name: null, + description: 'Glide kip to support on LB', + apparatusName: 'Uneven bars', + elementGroupName: 'Mounts', + elementGroupNumber: 1, + value: 0.1, + isVault: false, + hasVariants: true, + }, +] + +/** The drawing of a row, as the API gives it (a full address). */ +export const imageOf = (id: string) => + `http://images.test/${id.slice(0, 2)}/${id}.webp` + +export const exampleElementRows: ElementListRow[] = rowsWithoutImage.map( + (row) => ({ ...row, imageUrl: imageOf(row.id) }), +) + +/** How many results the example server says there are in all. */ +export const exampleTotalCount = 32