diff --git a/backend/src/catalog/search.test.ts b/backend/src/catalog/search.test.ts
index 71adfbb..1cb75a0 100644
--- a/backend/src/catalog/search.test.ts
+++ b/backend/src/catalog/search.test.ts
@@ -12,6 +12,7 @@ import {
BadUserInputError,
type CatalogSearchArgs,
listElementGroups,
+ MAX_TOTAL_COUNT,
searchCatalog,
searchTerms,
} from './search.js'
@@ -502,4 +503,54 @@ describe.runIf(hasDatabase)('searchCatalog on the seeded catalog', () => {
const page = await searchCatalog(prisma, { first: 1 })
expect(page.totalCount).toBeUndefined()
})
+
+ describe('the count stops at the most that is counted', () => {
+ const count = async (maxTotalCount?: number, search?: string) =>
+ (
+ await searchCatalog(prisma, {
+ first: 1,
+ search,
+ withTotalCount: true,
+ maxTotalCount,
+ })
+ ).totalCount
+
+ it('counts up to 1000 as it is, and the catalog is smaller than that', async () => {
+ expect(MAX_TOTAL_COUNT).toBe(1000)
+ expect(totalEntries).toBeLessThanOrEqual(MAX_TOTAL_COUNT)
+ expect(await count()).toBe(totalEntries)
+ })
+
+ it('stops at one more than the most, so a count above it means "more than that"', async () => {
+ expect(totalEntries).toBeGreaterThan(10)
+ expect(await count(10)).toBe(11)
+ expect(await count(1)).toBe(2)
+ })
+
+ it('is not capped when the most is as big as the number of matches, or bigger', async () => {
+ expect(await count(totalEntries)).toBe(totalEntries)
+ expect(await count(totalEntries + 500)).toBe(totalEntries)
+ // The most that is one less than the number of matches: one more than it is the
+ // number of matches, which is above the most.
+ expect(await count(totalEntries - 1)).toBe(totalEntries)
+ })
+
+ it('caps a search too, and a search with fewer matches than the most is counted as it is', async () => {
+ const all = await searchCatalog(prisma, {
+ first: 100,
+ search: 'handspring',
+ withTotalCount: true,
+ })
+ expect(all.totalCount).toBeGreaterThan(5)
+
+ expect(await count(5, 'handspring')).toBe(6)
+ expect(await count(all.totalCount! + 10, 'handspring')).toBe(
+ all.totalCount,
+ )
+ })
+
+ it('counts nothing for text with no searchable words, also with a cap', async () => {
+ expect(await count(10, '%')).toBe(0)
+ })
+ })
})
diff --git a/backend/src/catalog/search.ts b/backend/src/catalog/search.ts
index 6115d75..3b806fe 100644
--- a/backend/src/catalog/search.ts
+++ b/backend/src/catalog/search.ts
@@ -36,19 +36,33 @@ export type CatalogSearchArgs = {
/** Page size, 1–100. */
first?: number | null
after?: string | null
- /** Count all matches. Only set it when the client asks for totalCount. */
+ /** Count the matches. Only set it when the client asks for totalCount. */
withTotalCount?: boolean
+ /**
+ * The most matches that are counted, MAX_TOTAL_COUNT if it is left out. The count
+ * stops at one more than this, so a count above it means "more than this many".
+ */
+ maxTotalCount?: number
}
export type CatalogSearchResult = {
edges: { cursor: string; node: CatalogEntry }[]
pageInfo: { hasNextPage: boolean; endCursor: string | null }
- /** Only set when withTotalCount was true. */
+ /**
+ * Only set when withTotalCount was true. At most maxTotalCount + 1: a value above
+ * maxTotalCount means that there are more matches than that.
+ */
totalCount?: number
}
const DIRECTIONS: readonly SortDirection[] = ['ASC', 'DESC']
const MAX_PAGE_SIZE = 100
+/**
+ * How many matches `totalCount` counts. A search that has more matches gets one more
+ * than this (1001), which the interface shows as "1000+". The database stops counting
+ * there, so the cost of the count does not grow with the catalog.
+ */
+export const MAX_TOTAL_COUNT = 1000
/** Limits for the search text. Far more than anyone types, and each word adds a condition to the query. */
export const MAX_SEARCH_LENGTH = 80
export const MAX_SEARCH_WORDS = 8
@@ -237,9 +251,15 @@ export async function searchCatalog(
endCursor: edges.at(-1)?.cursor ?? null,
},
}
- // Counting reads every match, so it only runs when the client asks for it.
- if (args.withTotalCount)
- result.totalCount = await prisma.catalogEntry.count({ where })
+ // Counting reads the matches, so it only runs when the client asks for it, and it
+ // stops after one more than the most that is counted (take).
+ if (args.withTotalCount) {
+ const max = args.maxTotalCount ?? MAX_TOTAL_COUNT
+ result.totalCount = await prisma.catalogEntry.count({
+ where,
+ take: max + 1,
+ })
+ }
return result
}
diff --git a/backend/src/schema.graphql b/backend/src/schema.graphql
index 6dfa941..d3180e6 100644
--- a/backend/src/schema.graphql
+++ b/backend/src/schema.graphql
@@ -205,7 +205,8 @@ type CatalogConnection {
"""
Number of matches in the whole result set, not only this page. Counting is
expensive on a large result, so the server only counts when the client asks
- for this field.
+ for this field, and it stops at 1001: a value above 1000 means that there are
+ more than 1000 matches (the interface shows "1000+").
"""
totalCount: Int!
}
diff --git a/docs/AI_USAGE.md b/docs/AI_USAGE.md
index 57ddbf7..a97dde8 100644
--- a/docs/AI_USAGE.md
+++ b/docs/AI_USAGE.md
@@ -447,6 +447,19 @@
id, a stopped server and "Try again" work. The JavaScript bundle went from
665 kB to 522 kB.
+## The Elements page (`src/components/elements/ElementsPage.tsx`, `ResultsTable.tsx`)
+
+- **Tool:** Claude Code
+- **What the team did:** Drew the page in a design (`elements_page.png`) and
+ chose that it should look exactly like it, without lines between the rows. Decided that this pull request is only the
+ design, and that the search, filters, sort and paging are connected in #110.
+- **What Claude did:** Measured the colors in the design and used the existing
+ tokens for all of them but the purple of every other row (new:
+ `--table-row-alt`). Wrote the page and the table with the existing badge for
+ the circle. Checked the page at 390, 768, 927 and 1440 pixels wide in a
+ browser, and chose how the table becomes cards on a phone and how the whole
+ row is one link. Wrote the tests.
+
## Tests for the element page (`src/components/elements/ElementDetailPage.test.tsx`, `src/tests/apollo.ts`)
- **Tool:** Claude Code
@@ -460,3 +473,21 @@
page one at a time (the drawing, the warning, the disabled buttons, the
not-found page, "Try again", "Trying again…", keeping the current element and
the variant link) and checked that a test failed each time.
+
+## The search and the results of the Elements page (`src/components/elements/ElementsPage.tsx`, `src/lib/searchOperations.ts`)
+
+- **Tool:** Claude Code
+- **What the team did:** Decided that making the page work is done in parts, each with its own issue and pull request, and that the first part is the search field and the results (#117). Chose that the pull request is stacked on the design (#114) until that is merged.
+- **What Claude did:** Wrote the query `searchCatalog` and the page with the results, the loading, error and empty states, the debounce and the search in the address. Moved the example rows to a fixture for the tests. Wrote the tests on the real Apollo Client with a stub for `fetch`, and checked them by breaking five behaviors one at a time (the tests fail without each of them). Checked in a browser against the real backend: the first 20 of 562, a search, no result, a search from the address, and 9 words.
+
+## Load more elements (`src/components/elements/ElementsPage.tsx`, `src/lib/searchOperations.ts`)
+
+- **Tool:** Claude Code
+- **What the team did:** Decided that "Load more elements" is disabled when everything is shown ("Showing N of N"), and that all the work to make the page work is one issue (#110), in several pull requests.
+- **What Claude did:** Wrote the query for the next page (without `totalCount`, which the API counts only when it is asked), the button with its states (loading, disabled when everything is shown, error with a new try) and the tests, and checked the tests by breaking six behaviors one at a time. Checked in a browser against the real backend: 20, 40 and 60 of 562, one result (disabled at once), and 36 of 36 for a search that has two pages.
+
+## The count of the search stops at 1001 (`backend/src/catalog/search.ts`)
+
+- **Tool:** Claude Code
+- **What the team did:** Decided that the page says "1000+" when there are more than 1000 results, and that the server must not count more than that.
+- **What Claude did:** Made the database stop counting at 1001 (`count` with `take`), kept the schema the same (a `totalCount` above 1000 means more than 1000), wrote the tests (the count stops at one more than the most, it is not capped below that, and a search is capped too) and broke the cap to check that they fail without it.
diff --git a/docs/API.md b/docs/API.md
index e4bc931..7b61a51 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -158,7 +158,7 @@ The requirement is that the solution works with any amount of data. The catalog
- **Filter and sort need indexes.** `schema.prisma` declares an index for each sort field combined with `id` (`codeSort`, `difficulty`, and `hasName` with `nameSort`), and for the filter columns (apparatus, group). Without them the database sorts the whole catalog for every request. The name sort puts named items first in both directions (`hasName` descending), so ascending and descending each have their own index. The apparatus filter is often combined with a sort, so there are also indexes on apparatus with `difficulty` and with `codeSort`. The difficulty sort always has an apparatus filter, so it uses the index on apparatus and `difficulty`. The difficulty filter without an apparatus uses the index on `difficulty`. Without them, a filtered page walks past the rows of the other apparatus first, which is slow in one direction (for example vault sorted by ascending difficulty).
- **Sort by name needs a rule for items without a name.** Most items have no name. A cursor does not accept `nulls` placement, so the sort uses a plain `hasName` field and a `nameSort` field that is never `null` (see Search without raw SQL).
- **The text search must not scan the table.** A search for `%word%` cannot use an ordinary index. The search uses a word table with a B-tree index and a prefix match, which finds the rows that have a word starting with the search term without reading every row. A search that is correct but reads every row works on 500 rows and not on 5 million.
-- **`totalCount` is only calculated when the client asks for it.** Counting a large result takes time: about 30 ms for the whole catalog and about 100 ms for a search in a test with 225 000 entries, and the cost grows with the catalog. The resolver for the field runs only if `totalCount` is in the query, so a client that scrolls without showing a total does not pay for it. The client should therefore ask for `totalCount` only with the first page of a new search or filter, and not with the pages that are loaded while scrolling. Together with the debounce on the search field, this keeps the count from running on every keystroke. If the count is still too slow, it can be capped (for example "1000+"), which needs a change in the schema.
+- **`totalCount` is only calculated when the client asks for it.** Counting a large result takes time: about 30 ms for the whole catalog and about 100 ms for a search in a test with 225 000 entries, and the cost grows with the catalog. The resolver for the field runs only if `totalCount` is in the query, so a client that scrolls without showing a total does not pay for it. The client should therefore ask for `totalCount` only with the first page of a new search or filter, and not with the pages that are loaded while scrolling. Together with the debounce on the search field, this keeps the count from running on every keystroke. **The count is capped:** it stops at 1001 (`MAX_TOTAL_COUNT` is 1000, and the database counts one more), so the cost does not grow with the catalog. A `totalCount` above 1000 means that there are more than 1000 matches, and the interface shows "1000+". The schema is the same (`Int!`), only the meaning of a value above 1000 is new.
- **Elements and vaults are different tables, but one list.** A query cannot combine two tables into one sorted and paged result. The data model keeps them apart because they work differently, so the list is read from `CatalogEntry`, a read-only table with the shared columns that the seed script fills. It never gets out of date because the catalog never changes between seed runs.
- **Variants are few.** A variant group has two or three members, so `variants` does not need paging.
diff --git a/index.html b/index.html
index 8cd354a..626211a 100644
--- a/index.html
+++ b/index.html
@@ -7,7 +7,7 @@
t29-project-2
diff --git a/src/App.test.tsx b/src/App.test.tsx
index f161539..34958c7 100644
--- a/src/App.test.tsx
+++ b/src/App.test.tsx
@@ -84,14 +84,30 @@ describe('App', () => {
expect(screen.getAllByRole('main')).toHaveLength(1);
});
+ it('opens the search page from the start address, the logo and the Search link', () => {
+ renderApp();
+ expect(window.location.pathname).toBe(`${import.meta.env.BASE_URL}elements`);
+ expect(screen.getByRole('heading', { level: 1, name: 'Elements' })).toBeTruthy();
+
+ for (const link of [
+ () => screen.getByRole('link', { name: /wag cop planner home/i }),
+ () => screen.getByRole('link', { name: 'Search' }),
+ ]) {
+ fireEvent.click(screen.getByRole('link', { name: 'About' }));
+ expect(screen.getByRole('heading', { name: 'About' })).toBeTruthy();
+
+ fireEvent.click(link());
+ expect(window.location.pathname).toBe(`${import.meta.env.BASE_URL}elements`);
+ expect(screen.getByRole('heading', { level: 1, name: 'Elements' })).toBeTruthy();
+ }
+ });
+
it('guards the Gymnasts route and makes it available after login', () => {
window.history.replaceState({}, '', `${import.meta.env.BASE_URL}gymnasts`);
renderApp();
- // Logged out, /gymnasts goes to the start page, which shows an element.
- expect(window.location.pathname).toBe(
- `${import.meta.env.BASE_URL}elements/UB-1.101-a`,
- );
+ // Logged out, /gymnasts goes to the start page, which is the Elements page.
+ expect(window.location.pathname).toBe(`${import.meta.env.BASE_URL}elements`);
expect(screen.queryByRole('link', { name: 'Gymnasts' })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: 'Log in' }));
diff --git a/src/App.tsx b/src/App.tsx
index c2bdae4..10645b5 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -5,6 +5,7 @@ import { SkipLink } from './components/skipLink/SkipLink';
import { ProtectedRoute } from './components/ProtectedRoute';
import ElementDetailPage from './components/elements/ElementDetailPage';
+import ElementsPage from './components/elements/ElementsPage';
import YourGymnastsPage from './components/gymnasts/YourGymnastsPage';
import './index.css';
@@ -14,17 +15,17 @@ import './styles/global.css';
// Placeholder pages for routes without full implementations yet. Each page
// has its own , like the real pages.
const CalculatorPage = () => (
-
+
Calculator
);
const AboutPage = () => (
-
+
About
);
const NotFoundPage = () => (
-
+
404 - Page not found
);
@@ -56,10 +57,8 @@ export function App() {
{/* The pages have their own , so this is only the target of the skip link. */}
-
+
{element.isVault || !difficulty ? (
) : (
diff --git a/src/components/elements/ElementsPage.test.tsx b/src/components/elements/ElementsPage.test.tsx
new file mode 100644
index 0000000..e23bac5
--- /dev/null
+++ b/src/components/elements/ElementsPage.test.tsx
@@ -0,0 +1,577 @@
+// @vitest-environment jsdom
+// The page runs on the real Apollo Client, with a stub for `fetch` that answers like
+// the server (see src/tests/apollo.ts and "Search, filter, sort and pagination" in
+// docs/API.md).
+
+import { ApolloProvider } from '@apollo/client/react'
+import {
+ cleanup,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+ within,
+} from '@testing-library/react'
+import { print } from 'graphql'
+import { MemoryRouter, useLocation, useNavigate } from 'react-router-dom'
+import { afterEach, describe, expect, it } from 'vitest'
+import type { ElementListRow } from '@/lib/elementList'
+import {
+ MAX_SEARCH_LENGTH,
+ PAGE_SIZE,
+ SEARCH_CATALOG_MORE_QUERY,
+ SEARCH_CATALOG_QUERY,
+} from '@/lib/searchOperations'
+import {
+ createTestClient,
+ deferred,
+ failure,
+ ok,
+ type Answer,
+ type Handler,
+} from '@/tests/apollo'
+import { exampleElementRows, exampleTotalCount } from '@/tests/elementRows'
+import ElementsPage from './ElementsPage'
+
+afterEach(cleanup)
+
+/** What the server says for one row (an Element has `value`, a Vault has `dValue`). */
+const nodeOf = (row: ElementListRow) => ({
+ __typename: row.isVault ? 'Vault' : 'Element',
+ id: row.id,
+ code: row.code,
+ name: row.name,
+ description: row.description,
+ imageUrl: row.imageUrl,
+ elementGroup: {
+ __typename: 'ElementGroup',
+ name: row.elementGroupName,
+ number: row.elementGroupNumber,
+ apparatus: { __typename: 'Apparatus', name: row.apparatusName },
+ },
+ ...(row.isVault ? { dValue: row.value } : { value: row.value }),
+ variants: row.hasVariants
+ ? [{ __typename: 'Element', id: `${row.id}-x` }]
+ : [],
+})
+
+/** What the server says to `SearchCatalog`. */
+const searchAnswer = (
+ rows: ElementListRow[],
+ totalCount = rows.length,
+ hasNextPage = false,
+): Answer =>
+ ok({
+ searchCatalog: {
+ __typename: 'CatalogConnection',
+ totalCount,
+ pageInfo: {
+ __typename: 'PageInfo',
+ hasNextPage,
+ endCursor: rows.length ? rows[rows.length - 1].id : null,
+ },
+ edges: rows.map((row) => ({
+ __typename: 'CatalogEdge',
+ cursor: row.id,
+ node: nodeOf(row),
+ })),
+ },
+ })
+
+/** The server has seven rows, and finds only those with the search text in them. */
+const serverWithRows: Handler = ({ variables }) => {
+ const text = String(variables['search'] ?? '').toLowerCase()
+ const found = exampleElementRows.filter((row) =>
+ `${row.code} ${row.name ?? ''} ${row.description}`
+ .toLowerCase()
+ .includes(text),
+ )
+ return searchAnswer(found, text ? found.length : exampleTotalCount)
+}
+
+/**
+ * A server that has the seven rows in pages of `size`: the first page, and then the
+ * page after the row that `after` is the cursor of (the id of a row).
+ */
+const pagedServer =
+ (size: number): Handler =>
+ ({ variables }) => {
+ const after = variables['after']
+ const start = after
+ ? exampleElementRows.findIndex((row) => row.id === after) + 1
+ : 0
+ const page = exampleElementRows.slice(start, start + size)
+ return searchAnswer(
+ page,
+ exampleElementRows.length,
+ start + size < exampleElementRows.length,
+ )
+ }
+
+/** Shows the address, so a test can see that the search is in it. */
+function Address() {
+ const location = useLocation()
+ return (
+
+ )
+}
+
+/** A button that goes to another address, like the back button does. */
+function GoTo({ to }: { to: string }) {
+ const navigate = useNavigate()
+ return (
+
+ )
+}
+
+function renderPage(handler: Handler, address = '/elements', extra?: string) {
+ const { client, calls } = createTestClient(handler)
+ render(
+
+
+
+
+ {extra && }
+
+ ,
+ )
+ return { calls }
+}
+
+const field = () => screen.getByRole('searchbox')
+const address = () => screen.getByTestId('address').textContent
+const wait = { timeout: 3000 }
+
+describe('ElementsPage: the page', () => {
+ it('has one h1, and the results under an h2 that names the section', async () => {
+ renderPage(serverWithRows)
+
+ expect(screen.getAllByRole('heading', { level: 1 })).toHaveLength(1)
+ expect(screen.getByRole('region', { name: 'Results' })).toBeTruthy()
+ expect(
+ screen.getByRole('heading', { level: 2, name: 'Results' }),
+ ).toBeTruthy()
+ await screen.findByText(/Showing/)
+ })
+
+ it('has a labelled search field in a search landmark, that takes at most 80 characters', async () => {
+ renderPage(serverWithRows)
+
+ expect(screen.getByRole('search')).toBeTruthy()
+ expect(
+ screen.getByLabelText('Search elements by code, name or description'),
+ ).toBe(field())
+ expect(field().maxLength).toBe(MAX_SEARCH_LENGTH)
+ await screen.findByText(/Showing/)
+ })
+
+ it('has the filters in a labelled group, and a sort button that says what it sorts', async () => {
+ renderPage(serverWithRows)
+ await screen.findByText(/Showing/)
+
+ const filters = screen.getByRole('group', { name: 'Filters' })
+ for (const name of ['Apparatus', 'Element group', 'Difficulty']) {
+ expect(within(filters).getByRole('button', { name })).toBeTruthy()
+ }
+ expect(
+ screen.getByRole('button', { name: /Sort by Code\W+Ascending/ }),
+ ).toBeTruthy()
+ })
+
+ it('can reach every control with the keyboard, and each one shows where the focus is', async () => {
+ renderPage(pagedServer(4))
+ await screen.findByText(/Showing/)
+
+ const controls = [
+ ...screen.getAllByRole('button'),
+ ...screen.getAllByRole('link'),
+ field(),
+ ]
+ // The search field, the three filters, the sort button, "Load more" (there is a next
+ // page), and each row twice: in the table and in a card (CSS shows only one of
+ // them, and what is not shown cannot be reached with Tab).
+ expect(controls).toHaveLength(1 + 3 + 1 + 1 + 2 * 4)
+ for (const control of controls) {
+ expect(control.getAttribute('tabindex')).not.toBe('-1')
+ expect(control.hasAttribute('disabled')).toBe(false)
+ }
+ for (const button of screen.getAllByRole('button')) {
+ // outline-none on the base button hides the ring unless the style is set again.
+ expect(button.className).toContain('focus-visible:outline-solid')
+ expect(button.className).toContain('hover:bg-(--button-hover-bg)')
+ // The button of the design system turns its text to --foreground (almost white in
+ // dark mode) on hover, which cannot be read on the tan button.
+ expect(button.className).toContain('hover:text-(--button-text)')
+ expect(button.className).toContain('dark:hover:text-(--button-text)')
+ }
+ })
+
+ it('says so in a status when a filter or the sort is not connected yet, and not before', async () => {
+ renderPage(serverWithRows)
+ await screen.findByText(/Showing/)
+ const status = screen.getByTestId('controls-status')
+ expect(status.textContent).toBe('')
+
+ fireEvent.click(screen.getByRole('button', { name: 'Apparatus' }))
+ expect(status.textContent).toBe(
+ 'The apparatus filter is not connected yet.',
+ )
+
+ fireEvent.click(
+ screen.getByRole('button', { name: /Sort by Code\W+Ascending/ }),
+ )
+ expect(status.textContent).toBe('Sorting is not connected yet.')
+ })
+})
+
+describe('ElementsPage: the results from the API', () => {
+ it('asks for the first page, with no search text, and shows what the server says', async () => {
+ const { calls } = renderPage(serverWithRows)
+
+ expect(
+ await screen.findByText(
+ `Showing ${exampleElementRows.length} of ${exampleTotalCount}`,
+ ),
+ ).toBeTruthy()
+ expect(calls).toHaveLength(1)
+ expect(calls[0]).toMatchObject({
+ operationName: 'SearchCatalog',
+ variables: { search: null, first: PAGE_SIZE },
+ })
+ // Each row is in the table and in a card.
+ expect(screen.getAllByText(exampleElementRows[1].description)).toHaveLength(
+ 2,
+ )
+ expect(screen.getAllByText('Yurchenko').length).toBeGreaterThan(0)
+ })
+
+ it('says 1000+ and not the number when the server counts more than 1000 (it stops at 1001)', async () => {
+ renderPage(() => searchAnswer(exampleElementRows, 1001, true))
+
+ expect(
+ await screen.findByText(`Showing ${exampleElementRows.length} of 1000+`),
+ ).toBeTruthy()
+ expect(screen.queryByText(/1001/)).toBeNull()
+ })
+
+ it('says the number as it is up to 1000', async () => {
+ renderPage(() => searchAnswer(exampleElementRows, 1000, true))
+
+ expect(
+ await screen.findByText(`Showing ${exampleElementRows.length} of 1000`),
+ ).toBeTruthy()
+ })
+
+ it('shows the fields of the API in each row: the group number of a vault, the value, the drawing and the variants', async () => {
+ renderPage(serverWithRows)
+ await screen.findByText(/Showing/)
+
+ const vault = exampleElementRows.find((row) => row.isVault)!
+ const withVariants = exampleElementRows.find((row) => row.hasVariants)!
+ // The group of a vault is its number, and not the long title of the group.
+ expect(
+ screen.getAllByText(`Group ${vault.elementGroupNumber}`).length,
+ ).toBeGreaterThan(0)
+ expect(screen.queryByText(vault.elementGroupName)).toBeNull()
+ // The drawing is what the API says (imageUrl).
+ const images = screen.getAllByRole('img')
+ expect(images.map((image) => image.src)).toContain(vault.imageUrl)
+ // "More variants" only where the API lists variants (a card and a row).
+ expect(withVariants.hasVariants).toBe(true)
+ expect(screen.getAllByText('More variants')).toHaveLength(2)
+ })
+
+ it('says that the elements are loading, and then shows them', async () => {
+ const answer = deferred()
+ renderPage(() => answer.promise)
+
+ expect(
+ (await screen.findByText('Loading elements…')).getAttribute('role'),
+ ).toBe('status')
+ expect(
+ screen.getByRole('region', { name: 'Results' }).getAttribute('aria-busy'),
+ ).toBe('true')
+
+ answer.resolve(searchAnswer(exampleElementRows))
+ expect(await screen.findByText(/Showing/)).toBeTruthy()
+ expect(screen.queryByText('Loading elements…')).toBeNull()
+ })
+
+ it('says so when nothing is found, in a status a screen reader reads, with what was searched for', async () => {
+ renderPage(serverWithRows)
+ await screen.findByText(/Showing/)
+
+ fireEvent.change(field(), { target: { value: 'zzz' } })
+ const empty = await screen.findByText('No elements match “zzz”.', {}, wait)
+
+ expect(empty.getAttribute('role')).toBe('status')
+ expect(screen.queryByText(/Showing/)).toBeNull()
+ })
+
+ it('shows the error with "Try again" when the server fails, and loads again when the user tries again', async () => {
+ let attempt = 0
+ const { calls } = renderPage(() =>
+ ++attempt === 1
+ ? failure('INTERNAL_SERVER_ERROR', 'Something broke')
+ : searchAnswer(exampleElementRows),
+ )
+
+ const alert = await screen.findByRole('alert')
+ expect(alert.textContent).toBe(
+ 'Something went wrong on our side. Try again.',
+ )
+ fireEvent.click(screen.getByRole('button', { name: 'Try again' }))
+
+ expect(await screen.findByText(/Showing/)).toBeTruthy()
+ expect(screen.queryByRole('alert')).toBeNull()
+ expect(calls).toHaveLength(2)
+ })
+
+ it('shows what the server says when the search text is not allowed, without "Try again"', async () => {
+ renderPage(() => failure('BAD_USER_INPUT', 'Use at most 8 words.'))
+
+ expect((await screen.findByRole('alert')).textContent).toBe(
+ 'Use at most 8 words.',
+ )
+ expect(screen.queryByRole('button', { name: 'Try again' })).toBeNull()
+ })
+})
+
+describe('ElementsPage: the search', () => {
+ it('sends the text when the user has stopped typing, and not for each letter', async () => {
+ const { calls } = renderPage(serverWithRows)
+ await screen.findByText(/Showing/)
+
+ for (const text of ['y', 'yu', 'yur', 'yurchenko']) {
+ fireEvent.change(field(), { target: { value: text } })
+ }
+
+ await waitFor(() => expect(calls).toHaveLength(2), wait)
+ expect(calls[1].variables).toMatchObject({
+ search: 'yurchenko',
+ first: PAGE_SIZE,
+ })
+ expect(await screen.findByText('Showing 1 of 1', {}, wait)).toBeTruthy()
+ })
+
+ it('puts the search in the address, so a result can be shared, and takes it out when the field is empty', async () => {
+ renderPage(serverWithRows)
+ await screen.findByText(/Showing/)
+ expect(address()).toBe('/elements')
+
+ fireEvent.change(field(), { target: { value: ' Yurchenko ' } })
+ await waitFor(
+ () => expect(address()).toBe('/elements?search=Yurchenko'),
+ wait,
+ )
+
+ fireEvent.change(field(), { target: { value: '' } })
+ await waitFor(() => expect(address()).toBe('/elements'), wait)
+ expect(
+ await screen.findByText(
+ `Showing ${exampleElementRows.length} of ${exampleTotalCount}`,
+ {},
+ wait,
+ ),
+ ).toBeTruthy()
+ })
+
+ it('starts with the search of the address, in the field and in the request', async () => {
+ const { calls } = renderPage(serverWithRows, '/elements?search=amanar')
+
+ expect(field().value).toBe('amanar')
+ expect(await screen.findByText('Showing 1 of 1')).toBeTruthy()
+ expect(calls[0].variables).toMatchObject({ search: 'amanar' })
+ })
+
+ it('searches at once when the user presses Enter, and does not wait', async () => {
+ const { calls } = renderPage(serverWithRows)
+ await screen.findByText(/Showing/)
+
+ fireEvent.change(field(), { target: { value: 'biles' } })
+ fireEvent.submit(screen.getByRole('search'))
+
+ expect(address()).toBe('/elements?search=biles')
+ await waitFor(() => expect(calls).toHaveLength(2))
+ expect(calls[1].variables).toMatchObject({ search: 'biles' })
+ })
+
+ it('keeps the old results while a new search is on its way', async () => {
+ const second = deferred()
+ renderPage(({ variables }) =>
+ variables['search'] ? second.promise : searchAnswer(exampleElementRows),
+ )
+ await screen.findByText(/Showing/)
+
+ fireEvent.change(field(), { target: { value: 'biles' } })
+ fireEvent.submit(screen.getByRole('search'))
+
+ // The new answer has not come, and the old results are still there.
+ await waitFor(() => expect(address()).toBe('/elements?search=biles'))
+ expect(
+ screen.getByText(
+ `Showing ${exampleElementRows.length} of ${exampleElementRows.length}`,
+ ),
+ ).toBeTruthy()
+ expect(screen.queryByText('Loading elements…')).toBeNull()
+
+ second.resolve(searchAnswer([exampleElementRows[5]]))
+ expect(await screen.findByText('Showing 1 of 1')).toBeTruthy()
+ })
+
+ it('follows the address when the user goes back or forward: the field and the results change', async () => {
+ const { calls } = renderPage(
+ serverWithRows,
+ '/elements',
+ '/elements?search=amanar',
+ )
+ await screen.findByText(/Showing/)
+ expect(field().value).toBe('')
+
+ fireEvent.click(
+ screen.getByRole('button', { name: 'go to /elements?search=amanar' }),
+ )
+
+ await waitFor(() => expect(field().value).toBe('amanar'))
+ expect(await screen.findByText('Showing 1 of 1', {}, wait)).toBeTruthy()
+ expect(calls.at(-1)?.variables).toMatchObject({ search: 'amanar' })
+ })
+})
+
+describe('ElementsPage: Load more elements', () => {
+ const more = () =>
+ screen.getByRole('button', {
+ name: /Load more|Loading more/,
+ })
+ const tableRows = () => document.querySelectorAll('tbody tr')
+
+ it('fetches the next page with the cursor of the last row and adds it below, and the count follows', async () => {
+ const { calls } = renderPage(pagedServer(4))
+ await screen.findByText('Showing 4 of 7')
+ expect(tableRows()).toHaveLength(4)
+ expect(more().disabled).toBe(false)
+
+ fireEvent.click(more())
+
+ expect(await screen.findByText('Showing 7 of 7')).toBeTruthy()
+ expect(tableRows()).toHaveLength(7)
+ // The next page comes in the order of the server, after the first one.
+ const codes = [...tableRows()].map(
+ (row) => row.querySelector('td')?.textContent,
+ )
+ expect(codes).toEqual(exampleElementRows.map((row) => row.code))
+ expect(calls).toHaveLength(2)
+ expect(calls[1]).toMatchObject({
+ operationName: 'SearchCatalogMore',
+ variables: {
+ search: null,
+ first: PAGE_SIZE,
+ after: exampleElementRows[3].id,
+ },
+ })
+ })
+
+ it('does not ask for the count again with the next page', () => {
+ expect(print(SEARCH_CATALOG_QUERY)).toContain('totalCount')
+ expect(print(SEARCH_CATALOG_MORE_QUERY)).not.toContain('totalCount')
+ })
+
+ it('is disabled and says so while the next page is on its way, so a double click fetches it once', async () => {
+ const next = deferred()
+ const { calls } = renderPage((operation) =>
+ operation.variables['after'] ? next.promise : pagedServer(4)(operation),
+ )
+ await screen.findByText('Showing 4 of 7')
+
+ fireEvent.click(more())
+ await waitFor(() => expect(more().disabled).toBe(true))
+ expect(more().textContent).toBe('Loading more…')
+ expect(more().getAttribute('aria-busy')).toBe('true')
+ fireEvent.click(more())
+
+ next.resolve(searchAnswer(exampleElementRows.slice(4), 7))
+ expect(await screen.findByText('Showing 7 of 7')).toBeTruthy()
+ expect(calls).toHaveLength(2)
+ })
+
+ it('is disabled when everything is shown (N of N), and the count is what describes it', async () => {
+ renderPage(pagedServer(10))
+ const count = await screen.findByText('Showing 7 of 7')
+
+ expect(more().disabled).toBe(true)
+ expect(more().getAttribute('aria-describedby')).toBe(count.id)
+ expect(count.id).not.toBe('')
+ })
+
+ it('becomes disabled when the last page has come', async () => {
+ renderPage(pagedServer(4))
+ await screen.findByText('Showing 4 of 7')
+
+ fireEvent.click(more())
+
+ await screen.findByText('Showing 7 of 7')
+ expect(more().disabled).toBe(true)
+ expect(more().textContent).toBe('Load more elements')
+ })
+
+ it('shows the error, keeps what is shown, and lets the user press the button again', async () => {
+ let attempt = 0
+ const { calls } = renderPage((operation) => {
+ if (operation.variables['after'] && ++attempt === 1) {
+ return failure('INTERNAL_SERVER_ERROR', 'Something broke')
+ }
+ return pagedServer(4)(operation)
+ })
+ await screen.findByText('Showing 4 of 7')
+
+ fireEvent.click(more())
+
+ const alert = await screen.findByRole('alert')
+ expect(alert.textContent).toBe(
+ 'Something went wrong on our side. Try again. Press the button to try again.',
+ )
+ expect(tableRows()).toHaveLength(4)
+ expect(more().disabled).toBe(false)
+
+ fireEvent.click(more())
+
+ expect(await screen.findByText('Showing 7 of 7')).toBeTruthy()
+ expect(screen.queryByRole('alert')).toBeNull()
+ expect(calls).toHaveLength(3)
+ })
+
+ it('starts again with the first page when the search changes, and the error of the old search goes', async () => {
+ const { calls } = renderPage((operation) =>
+ operation.variables['after'] && !operation.variables['search']
+ ? failure('INTERNAL_SERVER_ERROR', 'Something broke')
+ : serverWithRowsPaged(operation),
+ )
+ await screen.findByText('Showing 4 of 7')
+ fireEvent.click(more())
+ await screen.findByRole('alert')
+
+ fireEvent.change(field(), { target: { value: 'yurchenko' } })
+ fireEvent.submit(screen.getByRole('search'))
+
+ expect(await screen.findByText('Showing 1 of 1', {}, wait)).toBeTruthy()
+ expect(screen.queryByRole('alert')).toBeNull()
+ const last = calls.at(-1)
+ expect(last?.operationName).toBe('SearchCatalog')
+ expect(last?.variables).toMatchObject({ search: 'yurchenko' })
+ expect(last?.variables['after']).toBeUndefined()
+ })
+})
+
+/** A server that pages the seven rows, and also finds rows by their text. */
+function serverWithRowsPaged(operation: Parameters[0]) {
+ const text = String(operation.variables['search'] ?? '').toLowerCase()
+ if (!text) return pagedServer(4)(operation)
+ const found = exampleElementRows.filter((row) =>
+ `${row.code} ${row.name ?? ''} ${row.description}`
+ .toLowerCase()
+ .includes(text),
+ )
+ return searchAnswer(found, found.length)
+}
diff --git a/src/components/elements/ElementsPage.tsx b/src/components/elements/ElementsPage.tsx
new file mode 100644
index 0000000..e84e201
--- /dev/null
+++ b/src/components/elements/ElementsPage.tsx
@@ -0,0 +1,250 @@
+import { useEffect, useRef, useState, type FormEvent } from 'react'
+import { useQuery } from '@apollo/client/react'
+import { useSearchParams } from 'react-router-dom'
+import { ArrowUpDown, ChevronDown, Plus, Search } from 'lucide-react'
+import ResultsTable from '@/components/elements/ResultsTable'
+import { Button } from '@/components/ui/button'
+import { describeError } from '@/lib/apollo/errors'
+import {
+ formatTotal,
+ MAX_SEARCH_LENGTH,
+ PAGE_SIZE,
+ SEARCH_CATALOG_MORE_QUERY,
+ SEARCH_CATALOG_QUERY,
+ SEARCH_DELAY_MS,
+ toListRow,
+} from '@/lib/searchOperations'
+
+const PILL =
+ 'h-10 rounded-full border-0 bg-(--button-bg) px-4 text-sm font-medium text-(--button-text) shadow-(--box-shadow) transition-colors hover:bg-(--button-hover-bg) focus-visible:bg-(--button-hover-bg) dark:border-transparent dark:bg-(--button-bg) dark:hover:bg-(--button-hover-bg) hover:text-(--button-text) dark:hover:text-(--button-text) focus-visible:ring-0 focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-(--text-color) motion-reduce:transition-none'
+
+const FILTERS = ['Apparatus', 'Element group', 'Difficulty']
+
+// TODO(#110): the filters and the sort are only drawn so far. Each one says that it is
+// not connected yet, like the buttons on the element page.
+export default function ElementsPage() {
+ const [status, setStatus] = useState('')
+ const [searchParams, setSearchParams] = useSearchParams()
+
+ // The search is in the address (?search=...), so a result can be shared and the
+ // back button works. `text` is what is in the field, and `search` is what the
+ // address says (what the search uses): the address follows the field when the user
+ // has stopped typing.
+ const search = (searchParams.get('search') ?? '').trim()
+ const [text, setText] = useState(searchParams.get('search') ?? '')
+ const written = useRef(search)
+
+ function writeAddress(value: string) {
+ const trimmed = value.trim()
+ if (trimmed === written.current) return
+ written.current = trimmed
+ setSearchParams(trimmed ? { search: trimmed } : {}, { replace: true })
+ }
+
+ useEffect(() => {
+ const timer = setTimeout(() => writeAddress(text), SEARCH_DELAY_MS)
+ return () => clearTimeout(timer)
+ // writeAddress only uses what is in the dependencies and the ref.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [text])
+
+ // The user went back or forward to another address: the field follows it.
+ useEffect(() => {
+ if (search !== written.current) {
+ written.current = search
+ setText(search)
+ }
+ }, [search])
+
+ const { data, previousData, loading, error, refetch, fetchMore } = useQuery(
+ SEARCH_CATALOG_QUERY,
+ { variables: { search: search || null, first: PAGE_SIZE } },
+ )
+ // While a new search is on its way the old results stay, so the page does not jump.
+ const shown = (data ?? previousData)?.searchCatalog
+ const rows = shown?.edges.map((edge) => toListRow(edge.node)) ?? []
+ const hasNextPage = shown?.pageInfo.hasNextPage ?? false
+
+ // "Load more": the next page is fetched with the cursor of the last one, and the
+ // cache adds it to the pages that are shown (src/lib/apollo/cache.ts). An error is
+ // for the search it happened in, and goes away with the next search.
+ const [loadingMore, setLoadingMore] = useState(false)
+ const [moreError, setMoreError] = useState<{
+ search: string
+ message: string
+ } | null>(null)
+
+ async function loadMore() {
+ const after = shown?.pageInfo.endCursor
+ if (!after || loadingMore) return
+ setLoadingMore(true)
+ setMoreError(null)
+ try {
+ await fetchMore({
+ query: SEARCH_CATALOG_MORE_QUERY,
+ variables: { search: search || null, first: PAGE_SIZE, after },
+ })
+ } catch (problem) {
+ setMoreError({ search, message: describeError(problem).message })
+ } finally {
+ setLoadingMore(false)
+ }
+ }
+
+ function submit(event: FormEvent) {
+ event.preventDefault()
+ writeAddress(text)
+ }
+
+ return (
+
+
Elements
+
+
+
+
+
+ {FILTERS.map((filter) => (
+
+ ))}
+
+
+
+
+
+ {status}
+
+
+
+
Results
+
+ {error ? (
+
+
+ {describeError(error).message}
+
+ {describeError(error).retryable && (
+
+ )}
+
+ ) : !shown ? (
+
+ Loading elements…
+
+ ) : rows.length === 0 ? (
+
+ {search
+ ? `No elements match “${search}”.`
+ : 'There are no elements.'}
+
+ ) : (
+ <>
+
+
+
+ Showing {rows.length} of {formatTotal(shown.totalCount)}
+
+ {/* Not pressable while the next page is on its way (a double click would
+ fetch it twice), and when everything is shown (Showing N of N): the
+ count says why. */}
+
+
+ {moreError?.search === search && (
+
+ {moreError.message} Press the button to try again.
+
+ )}
+ >
+ )}
+
+
+ )
+}
diff --git a/src/components/elements/ResultsTable.test.tsx b/src/components/elements/ResultsTable.test.tsx
new file mode 100644
index 0000000..ecab212
--- /dev/null
+++ b/src/components/elements/ResultsTable.test.tsx
@@ -0,0 +1,302 @@
+import { renderToString } from 'react-dom/server'
+import { MemoryRouter } from 'react-router-dom'
+import { describe, expect, it } from 'vitest'
+import { groupLabel, type ElementListRow } from '@/lib/elementList'
+import { exampleElementRows } from '@/tests/elementRows'
+import ResultsTable from './ResultsTable'
+
+const render = (rows: ElementListRow[]) =>
+ renderToString(
+
+
+ ,
+ )
+
+/** The cards (a phone) and the table (a wide screen) are both in the page. */
+const cardsOf = (markup: string) =>
+ /
]*md:hidden[^>]*>.*<\/ul>/s.exec(markup)?.[0] ?? ''
+const tableOf = (markup: string) => //s.exec(markup)?.[0] ?? ''
+
+const withVariants = exampleElementRows.find((row) => row.hasVariants)!
+const plain = exampleElementRows.find((row) => row.id === 'VT-1.22-a')!
+const named = exampleElementRows.find((row) => row.name)!
+const element = exampleElementRows.find((row) => !row.isVault)!
+
+describe('ResultsTable: the table (a wide screen)', () => {
+ it('is a table with a caption and the five column headings', () => {
+ const table = tableOf(render(exampleElementRows))
+
+ expect(table).toMatch(/
]*>[^<]*Elements and vaults/)
+ for (const heading of [
+ 'Code',
+ 'Element',
+ 'Apparatus',
+ 'Element group',
+ 'Difficulty',
+ ]) {
+ expect(table).toMatch(
+ new RegExp(
+ `
]*scope="col"[^>]*>(
]*>)?(]*>)?${heading}()?(
)?
`,
+ ),
+ )
+ }
+ })
+
+ it('is not shown on a phone, where the cards are', () => {
+ const markup = render(exampleElementRows)
+
+ expect(markup).toMatch(/
]*role="row"[^>]*class="group relative cursor-pointer/g,
+ ),
+ ).toHaveLength(exampleElementRows.length)
+ expect(table).toContain(`href="/elements/${plain.id}"`)
+ expect(table).toContain(plain.description)
+ })
+
+ it('answers on the whole row, by getting taller and not wider, and not by underlining the text', () => {
+ const table = tableOf(render(exampleElementRows))
+
+ expect(table).toContain('hover:*:shadow-[0_-5px_0_0_var(--row-hover)')
+ expect(table).toContain(
+ 'focus-within:*:shadow-[0_-5px_0_0_var(--row-hover)',
+ )
+ // The text only gets a very thin shadow in its own color (a little bolder). More
+ // than that makes it harder to read.
+ expect(table).toContain('hover:[text-shadow:0.15px_0_0_currentColor,')
+ expect(table).toContain(
+ 'focus-within:[text-shadow:0.15px_0_0_currentColor,',
+ )
+ expect(table).not.toContain('0.35px')
+ expect(table).not.toContain('hover:font-')
+ expect(table).not.toContain('scale-')
+ expect(table).not.toContain('underline')
+ })
+
+ it('has a chevron in each row that only appears on hover and focus, and is hidden from a screen reader', () => {
+ const table = tableOf(render(exampleElementRows))
+
+ const chevrons = table.match(/