diff --git a/README.md b/README.md
index 631877e..e139d7f 100644
--- a/README.md
+++ b/README.md
@@ -24,7 +24,7 @@ The calculator and About pages are placeholders. Adding items from the catalog t
| Review | What to look at |
| ------------------------------------------------------------------ | ---------------------------------------------------------------------------------- |
| [Running application](http://it2810-29.idi.ntnu.no/project2) | Search, load more, element details, and the Your gymnasts test login |
-| [ElementsPage tests](src/tests/pages/ElementsPage.test.tsx) | Search, result paging, URL state, loading, and error behavior |
+| [ElementsPage tests](src/tests/pages/) | Search, result paging, URL state, loading, and error behavior |
| [Element detail tests](src/tests/pages/ElementDetailPage.test.tsx) | Catalog detail rendering and error/loading states |
| [Gymnast page tests](src/tests/pages/YourGymnastsPage.test.tsx) | Test login, loading, profile creation, and error handling |
| [Backend catalog tests](backend/src/catalog/search.test.ts) | Search, filters, sorting, pagination, and count limits |
@@ -350,7 +350,7 @@ The search is done in PostgreSQL through Prisma (no raw SQL), with words matched
The frontend and the backend keep their tests in different places, on purpose:
-- **Frontend: `src/tests/`.** The tests are in the same folders as the code they test (`src/tests/pages/ElementsPage.test.tsx` tests `src/pages/ElementsPage.tsx`), and they import it with the `@/` alias. The folder also has what the tests share: `setupTests.ts`, the Apollo test client (`apollo.ts`) and the example rows (`elementRows.ts`). Keeping the tests apart from the code means that `src/pages/` and `src/components/` only show what the app is made of.
+- **Frontend: `src/tests/`.** The tests are in the same folders as the code they test (`src/tests/pages/AboutPage.test.tsx` tests `src/pages/AboutPage.tsx`), and they import it with the `@/` alias. The folder also has what the tests share: `setupTests.ts`, the Apollo test client (`apollo.ts`) and the example rows (`elementRows.ts`). Keeping the tests apart from the code means that `src/pages/` and `src/components/` only show what the app is made of.
- **Backend: next to the code, in `backend/src/`.** `backend/src/catalog/search.test.ts` tests `backend/src/catalog/search.ts`. The backend is its own package, with Node's module system and relative imports (`'../errors.js'`), and its folders are small and grouped by job (`auth/`, `catalog/`, `seed/`, `server/`), so a test next to its file is easy to find. This is the usual layout in a Node package. The tests that use the database need a migrated and seeded database (see "Database (local setup)").
- **End-to-end: `e2e/`** (see "End-to-end tests").
diff --git a/docs/ELEMENTS_PAGE.md b/docs/ELEMENTS_PAGE.md
index 5373dea..b513738 100644
--- a/docs/ELEMENTS_PAGE.md
+++ b/docs/ELEMENTS_PAGE.md
@@ -29,7 +29,7 @@ The Elements page (`/elements`, `src/pages/ElementsPage.tsx`) is where a coach s
## Testing
-- `src/tests/pages/ElementsPage.test.tsx` runs the page on the real Apollo Client with a stub for `fetch` that answers like the server: each filter, several values, the address in both directions, the disabled filters, clear, the sort list, the filters and the sort together, a wrong address, a structure that fails, and "Load more" with a filter. Each behavior was broken on purpose to check that a test fails without it.
+- `src/tests/pages/ElementsPage*.test.tsx` (one file for each topic, with the shared set-up in `src/tests/elementsPage.tsx`) run the page on the real Apollo Client with a stub for `fetch` that answers like the server: each filter, several values, the address in both directions, the disabled filters, clear, the sort list, the filters and the sort together, a wrong address, a structure that fails, and "Load more" with a filter. Each behavior was broken on purpose to check that a test fails without it.
- The backend tests (`backend/src/catalog/search.test.ts`, `backend/src/server/server.test.ts`) cover the vault filter, the range of the values and `Apparatus.difficultyValues`, and need the seeded database like the rest.
- Not everything can be tested in jsdom (for example the tooltip, which Base UI opens from real pointer events). Those parts were checked in a real browser against the real backend.
diff --git a/src/tests/elementsPage.tsx b/src/tests/elementsPage.tsx
new file mode 100644
index 0000000..75bcd99
--- /dev/null
+++ b/src/tests/elementsPage.tsx
@@ -0,0 +1,220 @@
+/* eslint-disable react-refresh/only-export-components -- test helpers, not part of the app, so there is no fast refresh to protect */
+// Shared by the tests of the Elements page (src/tests/pages/ElementsPage*.test.tsx).
+// 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 { fireEvent, render, screen } from '@testing-library/react'
+import { MemoryRouter, useLocation, useNavigate } from 'react-router-dom'
+import { type ElementListRow } from '@/lib/elementList'
+import {
+ createTestClient,
+ ok,
+ type Answer,
+ type Handler,
+ type Operation,
+} from '@/tests/apollo'
+import { exampleElementRows, exampleTotalCount } from '@/tests/elementRows'
+import ElementsPage from '@/pages/ElementsPage'
+
+/** What the server says for one row (an Element has `value`, a Vault has `dValue`). */
+export 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`. */
+export 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. */
+export 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).
+ */
+export 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. */
+export function Address() {
+ const location = useLocation()
+ return (
+
+ )
+}
+
+/** A button that goes to another address, like the back button does. */
+export function GoTo({ to }: { to: string }) {
+ const navigate = useNavigate()
+ return (
+
+ )
+}
+
+/** What the server says to `CatalogStructure`: the apparatus with their groups. */
+export const structure = [
+ {
+ __typename: 'Apparatus',
+ id: 'VT',
+ name: 'Vault',
+ difficultyValues: [1.6, 2, 2.4, 3.2],
+ 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',
+ difficultyValues: [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1],
+ 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.
+ */
+export function renderPage(
+ handler: Handler,
+ address = '/elements',
+ extra?: string,
+ // What the server says to the structure, by the number of the call (the first is 0).
+ structureAnswer: (call: number) => Answer = () =>
+ ok({ apparatus: structure }),
+) {
+ const calls: Operation[] = []
+ const structureCalls: Operation[] = []
+ const { client } = createTestClient((operation) => {
+ if (operation.operationName === 'CatalogStructure') {
+ structureCalls.push(operation)
+ return structureAnswer(structureCalls.length - 1)
+ }
+ calls.push(operation)
+ return handler(operation)
+ })
+ render(
+
+
+
+
+ {extra && }
+
+ ,
+ )
+ return { calls, structureCalls }
+}
+
+export const field = () => screen.getByRole('searchbox')
+export const address = () => screen.getByTestId('address').textContent
+export const wait = { timeout: 3000 }
+
+/** Opens a menu like a click does: the pointer goes down and up on the button. */
+export 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')
+}
+
+export const choose = (
+ role: 'menuitemradio' | 'menuitemcheckbox',
+ name: string,
+) => fireEvent.click(screen.getByRole(role, { name }))
+
+export const lastFilter = (calls: Operation[]) =>
+ calls.at(-1)!.variables['filter']
+
+export const chooseSort = (name: string) =>
+ fireEvent.click(screen.getByRole('menuitemradio', { name }))
+
+export const sortButton = (name = /Sort by Code: low to high/) =>
+ screen.getByRole('button', { name })
+
+/** A server that pages the seven rows, and also finds rows by their text. */
+export 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/tests/pages/ElementsPage.combined.test.tsx b/src/tests/pages/ElementsPage.combined.test.tsx
new file mode 100644
index 0000000..d10df55
--- /dev/null
+++ b/src/tests/pages/ElementsPage.combined.test.tsx
@@ -0,0 +1,104 @@
+// @vitest-environment jsdom
+
+import { cleanup, fireEvent, screen, waitFor } from '@testing-library/react'
+import { afterEach, describe, expect, it } from 'vitest'
+import {
+ serverWithRows,
+ renderPage,
+ address,
+ openMenu,
+ choose,
+ chooseSort,
+ sortButton,
+} from '@/tests/elementsPage'
+
+afterEach(cleanup)
+
+describe('ElementsPage: the search, the filters and the sort together', () => {
+ it('sends all three in one request, and keeps all three in the address', async () => {
+ const { calls } = renderPage(
+ serverWithRows,
+ '/elements?search=salto&apparatus=UB&group=UB-2&difficulty=A,B&sort=difficulty&dir=desc',
+ )
+ await screen.findByText(/Showing/)
+
+ expect(calls).toHaveLength(1)
+ expect(calls[0].variables).toMatchObject({
+ search: 'salto',
+ filter: {
+ apparatusId: 'UB',
+ elementGroupId: 'UB-2',
+ difficultyValues: [0.1, 0.2],
+ },
+ sort: { field: 'DIFFICULTY', direction: 'DESC' },
+ })
+ expect(sortButton(/Sort by Difficulty: high to low/)).toBeTruthy()
+
+ // A new filter keeps the search and the sort.
+ await openMenu(/^Difficulty: A, B/)
+ choose('menuitemcheckbox', 'C')
+ await waitFor(() =>
+ expect(address()).toBe(
+ '/elements?search=salto&apparatus=UB&group=UB-2&difficulty=A%2CB%2CC&sort=difficulty&dir=desc',
+ ),
+ )
+ })
+
+ it('can sort vault by difficulty, because the apparatus filter is set', async () => {
+ const { calls } = renderPage(serverWithRows)
+ await screen.findByText(/Showing/)
+ await openMenu('Apparatus')
+ choose('menuitemradio', 'Vault')
+ await waitFor(() => expect(address()).toBe('/elements?apparatus=VT'))
+
+ await openMenu(/Sort by/)
+ chooseSort('Difficulty: low to high')
+ await waitFor(() =>
+ expect(calls.at(-1)!.variables).toMatchObject({
+ filter: { apparatusId: 'VT' },
+ sort: { field: 'DIFFICULTY', direction: 'ASC' },
+ }),
+ )
+ })
+
+ it('falls back to sorting by code when the apparatus is cleared, so the API gets no BAD_USER_INPUT', async () => {
+ const { calls } = renderPage(
+ serverWithRows,
+ '/elements?apparatus=UB&sort=difficulty',
+ )
+ await screen.findByText(/Showing/)
+ expect(sortButton(/Sort by Difficulty/)).toBeTruthy()
+
+ fireEvent.click(screen.getByRole('button', { name: 'Clear filters' }))
+ await waitFor(() =>
+ expect(sortButton(/Sort by Code: low to high/)).toBeTruthy(),
+ )
+ await waitFor(() =>
+ expect(calls.at(-1)!.variables).not.toHaveProperty('sort'),
+ )
+ expect(calls.at(-1)!.variables['filter']).toBeNull()
+ })
+
+ it('offers Difficulty in the sort menu once an apparatus is chosen in the filter', async () => {
+ renderPage(serverWithRows)
+ await screen.findByText(/Showing/)
+ await openMenu(/Sort by/)
+ expect(
+ screen
+ .getByRole('menuitemradio', { name: 'Difficulty: low to high' })
+ .getAttribute('aria-disabled'),
+ ).toBe('true')
+ fireEvent.keyDown(screen.getByRole('menu'), { key: 'Escape' })
+ await waitFor(() => expect(screen.queryByRole('menu')).toBeNull())
+
+ await openMenu('Apparatus')
+ choose('menuitemradio', 'Uneven bars')
+ await waitFor(() => expect(address()).toBe('/elements?apparatus=UB'))
+ await openMenu(/Sort by/)
+ expect(
+ screen
+ .getByRole('menuitemradio', { name: 'Difficulty: low to high' })
+ .getAttribute('aria-disabled'),
+ ).not.toBe('true')
+ })
+})
diff --git a/src/tests/pages/ElementsPage.filters.test.tsx b/src/tests/pages/ElementsPage.filters.test.tsx
new file mode 100644
index 0000000..ed7691f
--- /dev/null
+++ b/src/tests/pages/ElementsPage.filters.test.tsx
@@ -0,0 +1,258 @@
+// @vitest-environment jsdom
+
+import { cleanup, fireEvent, screen, waitFor } from '@testing-library/react'
+import { afterEach, describe, expect, it } from 'vitest'
+import {
+ serverWithRows,
+ renderPage,
+ address,
+ openMenu,
+ choose,
+ lastFilter,
+} from '@/tests/elementsPage'
+
+afterEach(cleanup)
+
+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'))
+ // One choice is made, so the menu closes.
+ await waitFor(() => expect(screen.queryByRole('menu')).toBeNull())
+ 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('disables the group filter before an apparatus is chosen, still reachable by the keyboard, and says why', async () => {
+ renderPage(serverWithRows)
+ await screen.findByText(/Showing/)
+
+ const button = screen.getByRole('button', { name: 'Element group' })
+ const reason = 'Choose an apparatus to choose a group.'
+ // aria-disabled and not disabled: the keyboard can reach it, so the reason can be found.
+ expect(button.getAttribute('aria-disabled')).toBe('true')
+ expect(button.hasAttribute('disabled')).toBe(false)
+ expect(button.getAttribute('tabindex')).not.toBe('-1')
+ // A screen reader says the reason with the button.
+ expect(button.getAttribute('aria-describedby')).toBeTruthy()
+ expect(
+ document.getElementById(button.getAttribute('aria-describedby')!)
+ ?.textContent,
+ ).toBe(reason)
+
+ // It opens no menu and changes nothing when it is pressed, but the reason is shown: a
+ // tooltip does not open on a tap, so a touch user would not see it otherwise.
+ expect(screen.getAllByText(reason)).toHaveLength(1)
+ fireEvent.click(button)
+ expect(screen.queryByRole('menu')).toBeNull()
+ expect(address()).toBe('/elements')
+ await waitFor(() => expect(screen.getAllByText(reason)).toHaveLength(2))
+
+ // The tooltip (hover and focus) repeats the reason for sighted users. Base UI opens it
+ // from real pointer and focus events, which jsdom does not give, so it is checked in a browser.
+ })
+
+ it('enables the group filter when an apparatus is chosen', async () => {
+ renderPage(serverWithRows)
+ await screen.findByText(/Showing/)
+ await openMenu('Apparatus')
+ choose('menuitemradio', 'Uneven bars')
+
+ await waitFor(() =>
+ expect(
+ screen
+ .getByRole('button', { name: 'Element group' })
+ .getAttribute('aria-disabled'),
+ ).toBeNull(),
+ )
+ await openMenu('Element group')
+ expect(screen.getByRole('menuitemradio', { name: 'Mounts' })).toBeTruthy()
+ })
+
+ 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('filters vault by bands of its D-score, in ascending order, sent as the values inside', async () => {
+ const { calls } = renderPage(serverWithRows, '/elements?apparatus=VT')
+ await screen.findByText(/Showing/)
+ const button = screen.getByRole('button', { name: 'Difficulty' })
+ expect(button.getAttribute('aria-disabled')).toBeNull()
+ await openMenu('Difficulty')
+
+ // The bands that have a D-value (1.6, 2, 2.4, 3.2), and not 23 single values.
+ expect(
+ screen.getAllByRole('menuitemcheckbox').map((item) => item.textContent),
+ ).toEqual(['1.0–1.9', '2.0–2.9', '3.0–3.9'])
+ choose('menuitemcheckbox', '3.0–3.9')
+ choose('menuitemcheckbox', '2.0–2.9')
+
+ await waitFor(() =>
+ expect(address()).toBe('/elements?apparatus=VT&difficulty=2%2C3'),
+ )
+ // Each band is the D-values that exist inside it, so the API needs no ranges.
+ await waitFor(() =>
+ expect(lastFilter(calls)).toEqual({
+ apparatusId: 'VT',
+ difficultyValues: [2, 2.4, 3.2],
+ }),
+ )
+ expect(
+ screen.getByRole('button', { name: 'Difficulty: 2.0–2.9, 3.0–3.9' }),
+ ).toBeTruthy()
+ })
+
+ it('starts from the bands in the address, and leaves out letters, values and bands that do not exist', async () => {
+ const { calls } = renderPage(
+ serverWithRows,
+ '/elements?apparatus=VT&difficulty=3,A,9,2.4',
+ )
+ await screen.findByText(/Showing/)
+ expect(calls[0].variables['filter']).toEqual({
+ apparatusId: 'VT',
+ difficultyValues: [3.2],
+ })
+ })
+
+ it('drops the chosen difficulty when the apparatus changes between vault and the others, but not between two others', 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' }),
+ )
+
+ await openMenu('Difficulty')
+ choose('menuitemcheckbox', '2.0–2.9')
+ await waitFor(() => expect(address()).toContain('difficulty=2'))
+ await openMenu(/Apparatus/)
+ choose('menuitemradio', 'Uneven bars')
+ await waitFor(() => expect(address()).toBe('/elements?apparatus=UB'))
+ })
+
+ 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] }),
+ )
+ })
+})
diff --git a/src/tests/pages/ElementsPage.filtersAddress.test.tsx b/src/tests/pages/ElementsPage.filtersAddress.test.tsx
new file mode 100644
index 0000000..fff6270
--- /dev/null
+++ b/src/tests/pages/ElementsPage.filtersAddress.test.tsx
@@ -0,0 +1,238 @@
+// @vitest-environment jsdom
+
+import { ApolloProvider } from '@apollo/client/react'
+import {
+ cleanup,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+} from '@testing-library/react'
+import { MemoryRouter } from 'react-router-dom'
+import { afterEach, describe, expect, it } from 'vitest'
+import {
+ createTestClient,
+ deferred,
+ failure,
+ ok,
+ type Answer,
+ type Operation,
+} from '@/tests/apollo'
+import { exampleElementRows } from '@/tests/elementRows'
+import ElementsPage from '@/pages/ElementsPage'
+import {
+ searchAnswer,
+ serverWithRows,
+ pagedServer,
+ structure,
+ renderPage,
+ field,
+ address,
+ wait,
+ openMenu,
+ choose,
+ lastFilter,
+} from '@/tests/elementsPage'
+
+afterEach(cleanup)
+
+describe('ElementsPage: the filters, the address and the structure', () => {
+ 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('says what went wrong and does not search when the structure fails and the address has filters', async () => {
+ const { calls, structureCalls } = renderPage(
+ serverWithRows,
+ '/elements?apparatus=UB&difficulty=A',
+ undefined,
+ (call) =>
+ call === 0
+ ? failure('INTERNAL_SERVER_ERROR', 'Something broke')
+ : ok({ apparatus: structure }),
+ )
+
+ // The error, and no results under an address that says UB: the filters are not dropped.
+ expect(
+ await screen.findByText('Something went wrong on our side. Try again.'),
+ ).toBeTruthy()
+ expect(calls).toHaveLength(0)
+ expect(screen.queryByText(/Showing/)).toBeNull()
+ expect(address()).toBe('/elements?apparatus=UB&difficulty=A')
+
+ // Trying again loads the structure, and then searches with the filters of the address.
+ fireEvent.click(screen.getByRole('button', { name: 'Try again' }))
+ await screen.findByText(/Showing/)
+ expect(structureCalls).toHaveLength(2)
+ expect(calls).toHaveLength(1)
+ expect(calls[0].variables['filter']).toEqual({
+ apparatusId: 'UB',
+ difficultyValues: [0.1],
+ })
+ expect(screen.queryByRole('alert')).toBeNull()
+ })
+
+ it('still searches when the structure fails and the address has no filter', async () => {
+ const { calls } = renderPage(
+ serverWithRows,
+ '/elements?search=amanar',
+ undefined,
+ () => failure('INTERNAL_SERVER_ERROR', 'Something broke'),
+ )
+ await screen.findByText(/Showing/)
+ expect(calls).toHaveLength(1)
+ expect(calls[0].variables).toMatchObject({ search: 'amanar', filter: null })
+ })
+
+ 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()
+ // The button is gone, so the focus goes to the first filter and not to the page.
+ expect(document.activeElement).toBe(
+ screen.getByRole('button', { name: 'Apparatus' }),
+ )
+ })
+
+ 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()
+ })
+})
diff --git a/src/tests/pages/ElementsPage.loadMore.test.tsx b/src/tests/pages/ElementsPage.loadMore.test.tsx
new file mode 100644
index 0000000..a780a52
--- /dev/null
+++ b/src/tests/pages/ElementsPage.loadMore.test.tsx
@@ -0,0 +1,146 @@
+// @vitest-environment jsdom
+
+import { cleanup, fireEvent, screen, waitFor } from '@testing-library/react'
+import { print } from 'graphql'
+import { afterEach, describe, expect, it } from 'vitest'
+import {
+ PAGE_SIZE,
+ SEARCH_CATALOG_MORE_QUERY,
+ SEARCH_CATALOG_QUERY,
+} from '@/lib/searchOperations'
+import { deferred, failure, type Answer } from '@/tests/apollo'
+import { exampleElementRows } from '@/tests/elementRows'
+import {
+ searchAnswer,
+ pagedServer,
+ renderPage,
+ field,
+ wait,
+ serverWithRowsPaged,
+} from '@/tests/elementsPage'
+
+afterEach(cleanup)
+
+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()
+ })
+})
diff --git a/src/tests/pages/ElementsPage.results.test.tsx b/src/tests/pages/ElementsPage.results.test.tsx
new file mode 100644
index 0000000..c173774
--- /dev/null
+++ b/src/tests/pages/ElementsPage.results.test.tsx
@@ -0,0 +1,129 @@
+// @vitest-environment jsdom
+
+import { cleanup, fireEvent, screen } from '@testing-library/react'
+import { afterEach, describe, expect, it } from 'vitest'
+import { PAGE_SIZE } from '@/lib/searchOperations'
+import { deferred, failure, type Answer } from '@/tests/apollo'
+import { exampleElementRows, exampleTotalCount } from '@/tests/elementRows'
+import {
+ searchAnswer,
+ serverWithRows,
+ renderPage,
+ field,
+ wait,
+} from '@/tests/elementsPage'
+
+afterEach(cleanup)
+
+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()
+ })
+})
diff --git a/src/tests/pages/ElementsPage.search.test.tsx b/src/tests/pages/ElementsPage.search.test.tsx
new file mode 100644
index 0000000..8bfa3b5
--- /dev/null
+++ b/src/tests/pages/ElementsPage.search.test.tsx
@@ -0,0 +1,118 @@
+// @vitest-environment jsdom
+
+import { cleanup, fireEvent, screen, waitFor } from '@testing-library/react'
+import { afterEach, describe, expect, it } from 'vitest'
+import { PAGE_SIZE } from '@/lib/searchOperations'
+import { deferred, type Answer } from '@/tests/apollo'
+import { exampleElementRows, exampleTotalCount } from '@/tests/elementRows'
+import {
+ searchAnswer,
+ serverWithRows,
+ renderPage,
+ field,
+ address,
+ wait,
+} from '@/tests/elementsPage'
+
+afterEach(cleanup)
+
+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' })
+ })
+})
diff --git a/src/tests/pages/ElementsPage.sort.test.tsx b/src/tests/pages/ElementsPage.sort.test.tsx
new file mode 100644
index 0000000..e47fc4a
--- /dev/null
+++ b/src/tests/pages/ElementsPage.sort.test.tsx
@@ -0,0 +1,216 @@
+// @vitest-environment jsdom
+
+import { cleanup, fireEvent, screen, waitFor } from '@testing-library/react'
+import { afterEach, describe, expect, it } from 'vitest'
+import {
+ serverWithRows,
+ pagedServer,
+ renderPage,
+ field,
+ address,
+ wait,
+ openMenu,
+ chooseSort,
+ sortButton,
+} from '@/tests/elementsPage'
+
+afterEach(cleanup)
+
+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 one list of choices that each say the outcome, and the chosen one is checked', async () => {
+ renderPage(serverWithRows)
+ await screen.findByText(/Showing/)
+ await openMenu(/Sort by/)
+
+ const checked = (name: string) =>
+ screen.getByRole('menuitemradio', { name }).getAttribute('aria-checked')
+ const names = screen
+ .getAllByRole('menuitemradio')
+ .map((item) => item.textContent)
+ expect(names).toEqual([
+ 'Code: low to high',
+ 'Code: high to low',
+ 'Difficulty: low to high',
+ 'Difficulty: high to low',
+ 'Name: A to Z',
+ 'Name: Z to A',
+ ])
+ expect(checked('Code: low to high')).toBe('true')
+ expect(checked('Code: high to low')).toBe('false')
+ expect(checked('Name: A to Z')).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 openMenu(/Sort by/)
+ chooseSort('Name: A to Z')
+
+ 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: A to Z/)).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 openMenu(/Sort by/)
+ chooseSort('Code: high to low')
+
+ await waitFor(() => expect(address()).toBe('/elements?dir=desc'))
+ await waitFor(() =>
+ expect(calls.at(-1)!.variables).toMatchObject({
+ sort: { field: 'CODE', direction: 'DESC' },
+ }),
+ )
+
+ await openMenu(/Sort by/)
+ chooseSort('Code: low to high')
+ 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: Z to A/)).toBeTruthy()
+
+ await openMenu(/Sort by/)
+ chooseSort('Name: A to Z')
+ 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 openMenu(/Sort by/)
+ chooseSort('Name: A to Z')
+ 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 openMenu(/Sort by/)
+
+ const reason = screen.getByText(
+ 'Choose an apparatus to sort by difficulty.',
+ )
+ for (const name of ['Difficulty: low to high', 'Difficulty: high to low']) {
+ const difficulty = screen.getByRole('menuitemradio', { name })
+ expect(difficulty.getAttribute('aria-disabled')).toBe('true')
+ expect(difficulty.getAttribute('aria-describedby')).toBe(reason.id)
+ }
+ // The other choices are open.
+ expect(
+ screen
+ .getByRole('menuitemradio', { name: 'Name: A to Z' })
+ .getAttribute('aria-disabled'),
+ ).not.toBe('true')
+
+ fireEvent.click(
+ screen.getByRole('menuitemradio', { name: 'Difficulty: low to high' }),
+ )
+ 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/)
+
+ // The whole default sort, and not "Code: high to low" from the direction of the address.
+ expect(calls[0].variables).not.toHaveProperty('sort')
+ expect(sortButton(/Sort by Code: low to high/)).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 openMenu(/Sort by/)
+
+ const difficulty = screen.getByRole('menuitemradio', {
+ name: 'Difficulty: low to high',
+ })
+ expect(difficulty.getAttribute('aria-disabled')).not.toBe('true')
+ expect(screen.queryByText(/Choose an apparatus/)).toBeNull()
+ chooseSort('Difficulty: low to high')
+
+ 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: low to high/)).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/tests/pages/ElementsPage.test.tsx b/src/tests/pages/ElementsPage.test.tsx
index c75b6df..80f7881 100644
--- a/src/tests/pages/ElementsPage.test.tsx
+++ b/src/tests/pages/ElementsPage.test.tsx
@@ -1,205 +1,17 @@
// @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 { cleanup, screen, within } from '@testing-library/react'
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 { MAX_SEARCH_LENGTH } from '@/lib/searchOperations'
import {
- createTestClient,
- deferred,
- failure,
- ok,
- type Answer,
- type Handler,
- type Operation,
-} from '@/tests/apollo'
-import { exampleElementRows, exampleTotalCount } from '@/tests/elementRows'
-import ElementsPage from '@/pages/ElementsPage'
+ serverWithRows,
+ pagedServer,
+ renderPage,
+ field,
+} from '@/tests/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 (
-
- )
-}
-
-/** A button that goes to another address, like the back button does. */
-function GoTo({ to }: { to: string }) {
- const navigate = useNavigate()
- return (
-
- )
-}
-
-/** What the server says to `CatalogStructure`: the apparatus with their groups. */
-const structure = [
- {
- __typename: 'Apparatus',
- id: 'VT',
- name: 'Vault',
- difficultyValues: [1.6, 2, 2.4, 3.2],
- 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',
- difficultyValues: [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1],
- 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,
- // What the server says to the structure, by the number of the call (the first is 0).
- structureAnswer: (call: number) => Answer = () =>
- ok({ apparatus: structure }),
-) {
- const calls: Operation[] = []
- const structureCalls: Operation[] = []
- const { client } = createTestClient((operation) => {
- if (operation.operationName === 'CatalogStructure') {
- structureCalls.push(operation)
- return structureAnswer(structureCalls.length - 1)
- }
- calls.push(operation)
- return handler(operation)
- })
- render(
-
-
-
-
- {extra && }
-
- ,
- )
- return { calls, structureCalls }
-}
-
-const field = () => screen.getByRole('searchbox')
-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)
@@ -270,1106 +82,3 @@ 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'))
- // One choice is made, so the menu closes.
- await waitFor(() => expect(screen.queryByRole('menu')).toBeNull())
- 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('disables the group filter before an apparatus is chosen, still reachable by the keyboard, and says why', async () => {
- renderPage(serverWithRows)
- await screen.findByText(/Showing/)
-
- const button = screen.getByRole('button', { name: 'Element group' })
- const reason = 'Choose an apparatus to choose a group.'
- // aria-disabled and not disabled: the keyboard can reach it, so the reason can be found.
- expect(button.getAttribute('aria-disabled')).toBe('true')
- expect(button.hasAttribute('disabled')).toBe(false)
- expect(button.getAttribute('tabindex')).not.toBe('-1')
- // A screen reader says the reason with the button.
- expect(button.getAttribute('aria-describedby')).toBeTruthy()
- expect(
- document.getElementById(button.getAttribute('aria-describedby')!)
- ?.textContent,
- ).toBe(reason)
-
- // It opens no menu and changes nothing when it is pressed, but the reason is shown: a
- // tooltip does not open on a tap, so a touch user would not see it otherwise.
- expect(screen.getAllByText(reason)).toHaveLength(1)
- fireEvent.click(button)
- expect(screen.queryByRole('menu')).toBeNull()
- expect(address()).toBe('/elements')
- await waitFor(() => expect(screen.getAllByText(reason)).toHaveLength(2))
-
- // The tooltip (hover and focus) repeats the reason for sighted users. Base UI opens it
- // from real pointer and focus events, which jsdom does not give, so it is checked in a browser.
- })
-
- it('enables the group filter when an apparatus is chosen', async () => {
- renderPage(serverWithRows)
- await screen.findByText(/Showing/)
- await openMenu('Apparatus')
- choose('menuitemradio', 'Uneven bars')
-
- await waitFor(() =>
- expect(
- screen
- .getByRole('button', { name: 'Element group' })
- .getAttribute('aria-disabled'),
- ).toBeNull(),
- )
- await openMenu('Element group')
- expect(screen.getByRole('menuitemradio', { name: 'Mounts' })).toBeTruthy()
- })
-
- 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('filters vault by bands of its D-score, in ascending order, sent as the values inside', async () => {
- const { calls } = renderPage(serverWithRows, '/elements?apparatus=VT')
- await screen.findByText(/Showing/)
- const button = screen.getByRole('button', { name: 'Difficulty' })
- expect(button.getAttribute('aria-disabled')).toBeNull()
- await openMenu('Difficulty')
-
- // The bands that have a D-value (1.6, 2, 2.4, 3.2), and not 23 single values.
- expect(
- screen.getAllByRole('menuitemcheckbox').map((item) => item.textContent),
- ).toEqual(['1.0–1.9', '2.0–2.9', '3.0–3.9'])
- choose('menuitemcheckbox', '3.0–3.9')
- choose('menuitemcheckbox', '2.0–2.9')
-
- await waitFor(() =>
- expect(address()).toBe('/elements?apparatus=VT&difficulty=2%2C3'),
- )
- // Each band is the D-values that exist inside it, so the API needs no ranges.
- await waitFor(() =>
- expect(lastFilter(calls)).toEqual({
- apparatusId: 'VT',
- difficultyValues: [2, 2.4, 3.2],
- }),
- )
- expect(
- screen.getByRole('button', { name: 'Difficulty: 2.0–2.9, 3.0–3.9' }),
- ).toBeTruthy()
- })
-
- it('starts from the bands in the address, and leaves out letters, values and bands that do not exist', async () => {
- const { calls } = renderPage(
- serverWithRows,
- '/elements?apparatus=VT&difficulty=3,A,9,2.4',
- )
- await screen.findByText(/Showing/)
- expect(calls[0].variables['filter']).toEqual({
- apparatusId: 'VT',
- difficultyValues: [3.2],
- })
- })
-
- it('drops the chosen difficulty when the apparatus changes between vault and the others, but not between two others', 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' }),
- )
-
- await openMenu('Difficulty')
- choose('menuitemcheckbox', '2.0–2.9')
- await waitFor(() => expect(address()).toContain('difficulty=2'))
- await openMenu(/Apparatus/)
- choose('menuitemradio', 'Uneven bars')
- await waitFor(() => expect(address()).toBe('/elements?apparatus=UB'))
- })
-
- 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('says what went wrong and does not search when the structure fails and the address has filters', async () => {
- const { calls, structureCalls } = renderPage(
- serverWithRows,
- '/elements?apparatus=UB&difficulty=A',
- undefined,
- (call) =>
- call === 0
- ? failure('INTERNAL_SERVER_ERROR', 'Something broke')
- : ok({ apparatus: structure }),
- )
-
- // The error, and no results under an address that says UB: the filters are not dropped.
- expect(
- await screen.findByText('Something went wrong on our side. Try again.'),
- ).toBeTruthy()
- expect(calls).toHaveLength(0)
- expect(screen.queryByText(/Showing/)).toBeNull()
- expect(address()).toBe('/elements?apparatus=UB&difficulty=A')
-
- // Trying again loads the structure, and then searches with the filters of the address.
- fireEvent.click(screen.getByRole('button', { name: 'Try again' }))
- await screen.findByText(/Showing/)
- expect(structureCalls).toHaveLength(2)
- expect(calls).toHaveLength(1)
- expect(calls[0].variables['filter']).toEqual({
- apparatusId: 'UB',
- difficultyValues: [0.1],
- })
- expect(screen.queryByRole('alert')).toBeNull()
- })
-
- it('still searches when the structure fails and the address has no filter', async () => {
- const { calls } = renderPage(
- serverWithRows,
- '/elements?search=amanar',
- undefined,
- () => failure('INTERNAL_SERVER_ERROR', 'Something broke'),
- )
- await screen.findByText(/Showing/)
- expect(calls).toHaveLength(1)
- expect(calls[0].variables).toMatchObject({ search: 'amanar', filter: null })
- })
-
- 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()
- // The button is gone, so the focus goes to the first filter and not to the page.
- expect(document.activeElement).toBe(
- screen.getByRole('button', { name: 'Apparatus' }),
- )
- })
-
- 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()
- })
-})
-
-const chooseSort = (name: string) =>
- fireEvent.click(screen.getByRole('menuitemradio', { name }))
-
-const sortButton = (name = /Sort by Code: low to high/) =>
- screen.getByRole('button', { 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 one list of choices that each say the outcome, and the chosen one is checked', async () => {
- renderPage(serverWithRows)
- await screen.findByText(/Showing/)
- await openMenu(/Sort by/)
-
- const checked = (name: string) =>
- screen.getByRole('menuitemradio', { name }).getAttribute('aria-checked')
- const names = screen
- .getAllByRole('menuitemradio')
- .map((item) => item.textContent)
- expect(names).toEqual([
- 'Code: low to high',
- 'Code: high to low',
- 'Difficulty: low to high',
- 'Difficulty: high to low',
- 'Name: A to Z',
- 'Name: Z to A',
- ])
- expect(checked('Code: low to high')).toBe('true')
- expect(checked('Code: high to low')).toBe('false')
- expect(checked('Name: A to Z')).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 openMenu(/Sort by/)
- chooseSort('Name: A to Z')
-
- 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: A to Z/)).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 openMenu(/Sort by/)
- chooseSort('Code: high to low')
-
- await waitFor(() => expect(address()).toBe('/elements?dir=desc'))
- await waitFor(() =>
- expect(calls.at(-1)!.variables).toMatchObject({
- sort: { field: 'CODE', direction: 'DESC' },
- }),
- )
-
- await openMenu(/Sort by/)
- chooseSort('Code: low to high')
- 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: Z to A/)).toBeTruthy()
-
- await openMenu(/Sort by/)
- chooseSort('Name: A to Z')
- 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 openMenu(/Sort by/)
- chooseSort('Name: A to Z')
- 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 openMenu(/Sort by/)
-
- const reason = screen.getByText(
- 'Choose an apparatus to sort by difficulty.',
- )
- for (const name of ['Difficulty: low to high', 'Difficulty: high to low']) {
- const difficulty = screen.getByRole('menuitemradio', { name })
- expect(difficulty.getAttribute('aria-disabled')).toBe('true')
- expect(difficulty.getAttribute('aria-describedby')).toBe(reason.id)
- }
- // The other choices are open.
- expect(
- screen
- .getByRole('menuitemradio', { name: 'Name: A to Z' })
- .getAttribute('aria-disabled'),
- ).not.toBe('true')
-
- fireEvent.click(
- screen.getByRole('menuitemradio', { name: 'Difficulty: low to high' }),
- )
- 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/)
-
- // The whole default sort, and not "Code: high to low" from the direction of the address.
- expect(calls[0].variables).not.toHaveProperty('sort')
- expect(sortButton(/Sort by Code: low to high/)).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 openMenu(/Sort by/)
-
- const difficulty = screen.getByRole('menuitemradio', {
- name: 'Difficulty: low to high',
- })
- expect(difficulty.getAttribute('aria-disabled')).not.toBe('true')
- expect(screen.queryByText(/Choose an apparatus/)).toBeNull()
- chooseSort('Difficulty: low to high')
-
- 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: low to high/)).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()
- })
-})
-
-describe('ElementsPage: the search, the filters and the sort together', () => {
- it('sends all three in one request, and keeps all three in the address', async () => {
- const { calls } = renderPage(
- serverWithRows,
- '/elements?search=salto&apparatus=UB&group=UB-2&difficulty=A,B&sort=difficulty&dir=desc',
- )
- await screen.findByText(/Showing/)
-
- expect(calls).toHaveLength(1)
- expect(calls[0].variables).toMatchObject({
- search: 'salto',
- filter: {
- apparatusId: 'UB',
- elementGroupId: 'UB-2',
- difficultyValues: [0.1, 0.2],
- },
- sort: { field: 'DIFFICULTY', direction: 'DESC' },
- })
- expect(sortButton(/Sort by Difficulty: high to low/)).toBeTruthy()
-
- // A new filter keeps the search and the sort.
- await openMenu(/^Difficulty: A, B/)
- choose('menuitemcheckbox', 'C')
- await waitFor(() =>
- expect(address()).toBe(
- '/elements?search=salto&apparatus=UB&group=UB-2&difficulty=A%2CB%2CC&sort=difficulty&dir=desc',
- ),
- )
- })
-
- it('can sort vault by difficulty, because the apparatus filter is set', async () => {
- const { calls } = renderPage(serverWithRows)
- await screen.findByText(/Showing/)
- await openMenu('Apparatus')
- choose('menuitemradio', 'Vault')
- await waitFor(() => expect(address()).toBe('/elements?apparatus=VT'))
-
- await openMenu(/Sort by/)
- chooseSort('Difficulty: low to high')
- await waitFor(() =>
- expect(calls.at(-1)!.variables).toMatchObject({
- filter: { apparatusId: 'VT' },
- sort: { field: 'DIFFICULTY', direction: 'ASC' },
- }),
- )
- })
-
- it('falls back to sorting by code when the apparatus is cleared, so the API gets no BAD_USER_INPUT', async () => {
- const { calls } = renderPage(
- serverWithRows,
- '/elements?apparatus=UB&sort=difficulty',
- )
- await screen.findByText(/Showing/)
- expect(sortButton(/Sort by Difficulty/)).toBeTruthy()
-
- fireEvent.click(screen.getByRole('button', { name: 'Clear filters' }))
- await waitFor(() =>
- expect(sortButton(/Sort by Code: low to high/)).toBeTruthy(),
- )
- await waitFor(() =>
- expect(calls.at(-1)!.variables).not.toHaveProperty('sort'),
- )
- expect(calls.at(-1)!.variables['filter']).toBeNull()
- })
-
- it('offers Difficulty in the sort menu once an apparatus is chosen in the filter', async () => {
- renderPage(serverWithRows)
- await screen.findByText(/Showing/)
- await openMenu(/Sort by/)
- expect(
- screen
- .getByRole('menuitemradio', { name: 'Difficulty: low to high' })
- .getAttribute('aria-disabled'),
- ).toBe('true')
- fireEvent.keyDown(screen.getByRole('menu'), { key: 'Escape' })
- await waitFor(() => expect(screen.queryByRole('menu')).toBeNull())
-
- await openMenu('Apparatus')
- choose('menuitemradio', 'Uneven bars')
- await waitFor(() => expect(address()).toBe('/elements?apparatus=UB'))
- await openMenu(/Sort by/)
- expect(
- screen
- .getByRole('menuitemradio', { name: 'Difficulty: low to high' })
- .getAttribute('aria-disabled'),
- ).not.toBe('true')
- })
-})
-
-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)
-}