Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/AI_USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -479,3 +479,9 @@
- **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.
204 changes: 191 additions & 13 deletions src/components/elements/ElementsPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,16 @@ import {
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 } from '@/lib/searchOperations'
import {
MAX_SEARCH_LENGTH,
PAGE_SIZE,
SEARCH_CATALOG_MORE_QUERY,
SEARCH_CATALOG_QUERY,
} from '@/lib/searchOperations'
import {
createTestClient,
deferred,
Expand Down Expand Up @@ -53,12 +59,17 @@ const nodeOf = (row: ElementListRow) => ({
const searchAnswer = (
rows: ElementListRow[],
totalCount = rows.length,
hasNextPage = false,
): Answer =>
ok({
searchCatalog: {
__typename: 'CatalogConnection',
totalCount,
pageInfo: { __typename: 'PageInfo', hasNextPage: false, endCursor: null },
pageInfo: {
__typename: 'PageInfo',
hasNextPage,
endCursor: rows.length ? rows[rows.length - 1].id : null,
},
edges: rows.map((row) => ({
__typename: 'CatalogEdge',
cursor: row.id,
Expand All @@ -78,6 +89,25 @@ const serverWithRows: Handler = ({ variables }) => {
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()
Expand Down Expand Up @@ -151,18 +181,18 @@ describe('ElementsPage: the page', () => {
})

it('can reach every control with the keyboard, and each one shows where the focus is', async () => {
renderPage(serverWithRows)
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", 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 * exampleElementRows.length)
// 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)
Expand All @@ -178,7 +208,7 @@ describe('ElementsPage: the page', () => {
}
})

it('says so in a status when a control is not connected yet, and not before', async () => {
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')
Expand All @@ -193,11 +223,6 @@ describe('ElementsPage: the page', () => {
screen.getByRole('button', { name: /Sort by Code\W+Ascending/ }),
)
expect(status.textContent).toBe('Sorting is not connected yet.')

fireEvent.click(screen.getByRole('button', { name: 'Load more elements' }))
expect(status.textContent).toBe(
'Loading more elements is not connected yet.',
)
})
})

Expand All @@ -222,6 +247,23 @@ describe('ElementsPage: the results from the API', () => {
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/)
Expand Down Expand Up @@ -397,3 +439,139 @@ describe('ElementsPage: the search', () => {
expect(calls.at(-1)?.variables).toMatchObject({ search: 'amanar' })
})
})

describe('ElementsPage: Load more elements', () => {
const more = () =>
screen.getByRole<HTMLButtonElement>('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<Answer>()
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<Handler>[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)
}
58 changes: 50 additions & 8 deletions src/components/elements/ElementsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ 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,
Expand All @@ -18,8 +20,8 @@ const PILL =

const FILTERS = ['Apparatus', 'Element group', 'Difficulty']

// TODO(#110): the filters, the sort and "Load more" are only drawn so far. Each one
// says that it is not connected yet, like the buttons on the element page.
// TODO(#110): the filters 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()
Expand Down Expand Up @@ -54,13 +56,40 @@ export default function ElementsPage() {
}
}, [search])

const { data, previousData, loading, error, refetch } = useQuery(
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()
Expand Down Expand Up @@ -182,24 +211,37 @@ export default function ElementsPage() {
<ResultsTable rows={rows} />
<div className="mt-4 flex flex-wrap items-center justify-between gap-3">
<p
id="results-count"
role="status"
aria-live="polite"
className="text-sm font-medium text-(--text-color)"
>
Showing {rows.length} of {shown.totalCount}
Showing {rows.length} of {formatTotal(shown.totalCount)}
</p>
{/* 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. */}
<Button
type="button"
variant="outline"
className={PILL}
onClick={() =>
setStatus('Loading more elements is not connected yet.')
}
disabled={!hasNextPage || loadingMore || loading}
aria-busy={loadingMore}
aria-describedby="results-count"
onClick={() => void loadMore()}
>
<Plus aria-hidden="true" />
Load more elements
{loadingMore ? 'Loading more…' : 'Load more elements'}
</Button>
</div>
{moreError?.search === search && (
<p
role="alert"
className="mt-3 text-sm font-medium text-(--text-color)"
>
{moreError.message} Press the button to try again.
</p>
)}
</>
)}
</section>
Expand Down
Loading