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
206 changes: 202 additions & 4 deletions src/components/elements/ElementsPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ describe('ElementsPage: the page', () => {
}
})

it('says so in a status when a filter or the sort is not connected yet, and not before', async () => {
it('says so in a status when a filter is not connected yet, and not before', async () => {
renderPage(serverWithRows)
await screen.findByText(/Showing/)
const status = screen.getByTestId('controls-status')
Expand All @@ -218,11 +218,209 @@ describe('ElementsPage: the page', () => {
expect(status.textContent).toBe(
'The apparatus filter is not connected yet.',
)
})
})

fireEvent.click(
screen.getByRole('button', { name: /Sort by Code\W+Ascending/ }),
const sortButton = (name = /Sort by Code\W+Ascending/) =>
screen.getByRole('button', { name })

/** Opens the menu like a click does: the pointer goes down and up on the button. */
async function openSortMenu() {
const button = sortButton(/Sort by/)
fireEvent.pointerDown(button, { button: 0, pointerType: 'mouse' })
fireEvent.mouseDown(button, { button: 0 })
fireEvent.pointerUp(button, { button: 0, pointerType: 'mouse' })
fireEvent.mouseUp(button, { button: 0 })
fireEvent.click(button, { button: 0 })
return screen.findByRole('menu')
}

const choose = (name: string) =>
fireEvent.click(screen.getByRole('menuitemradio', { name }))

describe('ElementsPage: the sort', () => {
it('sends no sort when it is the default (code, ascending), and keeps the address clean', async () => {
const { calls } = renderPage(serverWithRows)
await screen.findByText(/Showing/)

expect(calls[0].variables).not.toHaveProperty('sort')
expect(address()).toBe('/elements')
})

it('has the fields and the directions as radio items, and the chosen ones are checked', async () => {
renderPage(serverWithRows)
await screen.findByText(/Showing/)
await openSortMenu()

const checked = (name: string) =>
screen.getByRole('menuitemradio', { name }).getAttribute('aria-checked')
for (const name of [
'Code',
'Difficulty',
'Name',
'Ascending',
'Descending',
])
expect(screen.getByRole('menuitemradio', { name })).toBeTruthy()
expect(checked('Code')).toBe('true')
expect(checked('Ascending')).toBe('true')
expect(checked('Name')).toBe('false')
expect(checked('Descending')).toBe('false')
})

it('sorts by name: sends it, puts it in the address and says it on the button', async () => {
const { calls } = renderPage(serverWithRows)
await screen.findByText(/Showing/)
await openSortMenu()
choose('Name')

await waitFor(() => expect(address()).toBe('/elements?sort=name'))
await waitFor(() =>
expect(calls.at(-1)!.variables).toMatchObject({
sort: { field: 'NAME', direction: 'ASC' },
}),
)
expect(sortButton(/Sort by Name\W+Ascending/)).toBeTruthy()
})

it('sorts descending, and goes back to a clean address with the default again', async () => {
const { calls } = renderPage(serverWithRows)
await screen.findByText(/Showing/)
await openSortMenu()
choose('Descending')

await waitFor(() => expect(address()).toBe('/elements?dir=desc'))
await waitFor(() =>
expect(calls.at(-1)!.variables).toMatchObject({
sort: { field: 'CODE', direction: 'DESC' },
}),
)
expect(status.textContent).toBe('Sorting is not connected yet.')

await openSortMenu()
choose('Ascending')
await waitFor(() => expect(address()).toBe('/elements'))
})

it('starts from the sort in the address, and keeps the search when the sort changes', async () => {
const { calls } = renderPage(
serverWithRows,
'/elements?search=amanar&sort=name&dir=desc',
)
await screen.findByText(/Showing/)

expect(calls[0].variables).toMatchObject({
search: 'amanar',
sort: { field: 'NAME', direction: 'DESC' },
})
expect(field().value).toBe('amanar')
expect(sortButton(/Sort by Name\W+Descending/)).toBeTruthy()

await openSortMenu()
choose('Ascending')
await waitFor(() =>
expect(address()).toBe('/elements?search=amanar&sort=name'),
)
})

it('keeps the sort in the address when the search changes', async () => {
renderPage(serverWithRows, '/elements?sort=name')
await screen.findByText(/Showing/)

fireEvent.change(field(), { target: { value: 'biles' } })
await waitFor(
() => expect(address()).toBe('/elements?sort=name&search=biles'),
wait,
)
})

it('starts a new sort from the first page, and loads more with the same sort', async () => {
const { calls } = renderPage(pagedServer(4))
await screen.findByText(/Showing 4 of/)

fireEvent.click(screen.getByRole('button', { name: 'Load more elements' }))
await screen.findByText(/Showing 7 of/)
expect(calls.at(-1)).toMatchObject({
operationName: 'SearchCatalogMore',
variables: { after: expect.any(String) },
})

await openSortMenu()
choose('Name')
await screen.findByText(/Showing 4 of/)
const last = calls.at(-1)!
expect(last.operationName).toBe('SearchCatalog')
expect(last.variables).not.toHaveProperty('after')
expect(last.variables).toMatchObject({ sort: { field: 'NAME' } })

fireEvent.click(screen.getByRole('button', { name: 'Load more elements' }))
await screen.findByText(/Showing 7 of/)
expect(calls.at(-1)!.variables).toMatchObject({
sort: { field: 'NAME', direction: 'ASC' },
after: expect.any(String),
})
})

it('cannot sort by difficulty without an apparatus, and says why', async () => {
const { calls } = renderPage(serverWithRows)
await screen.findByText(/Showing/)
await openSortMenu()

const difficulty = screen.getByRole('menuitemradio', { name: 'Difficulty' })
expect(difficulty.getAttribute('aria-disabled')).toBe('true')
const reason = screen.getByText(
'Choose an apparatus to sort by difficulty.',
)
expect(difficulty.getAttribute('aria-describedby')).toBe(reason.id)

fireEvent.click(difficulty)
expect(address()).toBe('/elements')
expect(calls).toHaveLength(1)
})

it('does not send difficulty without an apparatus, even if the address says so (BAD_USER_INPUT)', async () => {
const { calls } = renderPage(
serverWithRows,
'/elements?sort=difficulty&dir=desc',
)
await screen.findByText(/Showing/)

expect(calls[0].variables).toMatchObject({
sort: { field: 'CODE', direction: 'DESC' },
})
expect(sortButton(/Sort by Code\W+Descending/)).toBeTruthy()
})

it('can sort by difficulty with an apparatus, and sends the apparatus with it', async () => {
const { calls } = renderPage(serverWithRows, '/elements?apparatus=VT')
await screen.findByText(/Showing/)
await openSortMenu()

const difficulty = screen.getByRole('menuitemradio', { name: 'Difficulty' })
expect(difficulty.getAttribute('aria-disabled')).not.toBe('true')
expect(screen.queryByText(/Choose an apparatus/)).toBeNull()
choose('Difficulty')

await waitFor(() =>
expect(address()).toBe('/elements?apparatus=VT&sort=difficulty'),
)
await waitFor(() =>
expect(calls.at(-1)!.variables).toMatchObject({
filter: { apparatusId: 'VT' },
sort: { field: 'DIFFICULTY', direction: 'ASC' },
}),
)
expect(sortButton(/Sort by Difficulty\W+Ascending/)).toBeTruthy()
})

it('ignores a sort in the address that it does not know', async () => {
const { calls } = renderPage(
serverWithRows,
'/elements?sort=banana&dir=sideways',
)
await screen.findByText(/Showing/)

expect(calls[0].variables).not.toHaveProperty('sort')
expect(sortButton()).toBeTruthy()
})
})

Expand Down
72 changes: 57 additions & 15 deletions src/components/elements/ElementsPage.tsx
Original file line number Diff line number Diff line change
@@ -1,27 +1,31 @@
import { useEffect, useRef, useState, type FormEvent } from 'react'
import { useQuery } from '@apollo/client/react'
import { useSearchParams } from 'react-router-dom'
import { ArrowUpDown, ChevronDown, Plus, Search } from 'lucide-react'
import { ChevronDown, Plus, Search } from 'lucide-react'
import ResultsTable from '@/components/elements/ResultsTable'
import SortMenu from '@/components/elements/SortMenu'
import { Button } from '@/components/ui/button'
import { describeError } from '@/lib/apollo/errors'
import {
formatTotal,
MAX_SEARCH_LENGTH,
PAGE_SIZE,
parseSort,
SEARCH_CATALOG_MORE_QUERY,
SEARCH_CATALOG_QUERY,
SEARCH_DELAY_MS,
sortParams,
toListRow,
type Sort,
} from '@/lib/searchOperations'

const PILL =
'h-10 rounded-full border-0 bg-(--button-bg) px-4 text-sm font-medium text-(--button-text) shadow-(--box-shadow) transition-colors hover:bg-(--button-hover-bg) focus-visible:bg-(--button-hover-bg) dark:border-transparent dark:bg-(--button-bg) dark:hover:bg-(--button-hover-bg) hover:text-(--button-text) dark:hover:text-(--button-text) focus-visible:ring-0 focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-(--text-color) motion-reduce:transition-none'

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

// TODO(#110): the filters and the sort are only drawn so far. Each one says that it is
// not connected yet, like the buttons on the element page.
// TODO(#110): the filters are only drawn so far. Each one says that it is not connected
// yet, like the buttons on the element page. The sort is connected (#128).
export default function ElementsPage() {
const [status, setStatus] = useState('')
const [searchParams, setSearchParams] = useSearchParams()
Expand All @@ -34,11 +38,45 @@ export default function ElementsPage() {
const [text, setText] = useState(searchParams.get('search') ?? '')
const written = useRef(search)

// The sort is in the address too (?sort=difficulty&dir=desc, the default left out).
// Difficulty needs an apparatus; the apparatus filter will be in the address as
// ?apparatus=..., and until it is connected nothing sets it.
const apparatusId = searchParams.get('apparatus')
const sort = parseSort(
searchParams.get('sort'),
searchParams.get('dir'),
Boolean(apparatusId),
)

// Writing one part of the address keeps the others.
function writeAddress(value: string) {
const trimmed = value.trim()
if (trimmed === written.current) return
written.current = trimmed
setSearchParams(trimmed ? { search: trimmed } : {}, { replace: true })
setSearchParams(
(params) => {
const next = new URLSearchParams(params)
if (trimmed) next.set('search', trimmed)
else next.delete('search')
return next
},
{ replace: true },
)
}

// A new sort is a new list (the cache keys it), so it starts from the first page.
function changeSort(chosen: Sort) {
setSearchParams(
(params) => {
const next = new URLSearchParams(params)
next.delete('sort')
next.delete('dir')
for (const [key, value] of Object.entries(sortParams(chosen)))
next.set(key, value)
return next
},
{ replace: true },
)
}

useEffect(() => {
Expand All @@ -56,9 +94,17 @@ export default function ElementsPage() {
}
}, [search])

// What the search is sent with. The default sort is not sent, so the first request is
// the same as before the sort was connected.
const searchVariables = {
search: search || null,
...(apparatusId && { filter: { apparatusId } }),
...(sort.field !== 'CODE' || sort.direction !== 'ASC' ? { sort } : {}),
}

const { data, previousData, loading, error, refetch, fetchMore } = useQuery(
SEARCH_CATALOG_QUERY,
{ variables: { search: search || null, first: PAGE_SIZE } },
{ variables: { ...searchVariables, first: PAGE_SIZE } },
)
// While a new search is on its way the old results stay, so the page does not jump.
const shown = (data ?? previousData)?.searchCatalog
Expand All @@ -82,7 +128,7 @@ export default function ElementsPage() {
try {
await fetchMore({
query: SEARCH_CATALOG_MORE_QUERY,
variables: { search: search || null, first: PAGE_SIZE, after },
variables: { ...searchVariables, first: PAGE_SIZE, after },
})
} catch (problem) {
setMoreError({ search, message: describeError(problem).message })
Expand Down Expand Up @@ -144,16 +190,12 @@ export default function ElementsPage() {
</Button>
))}
</div>
<Button
type="button"
variant="outline"
<SortMenu
sort={sort}
hasApparatus={Boolean(apparatusId)}
onChange={changeSort}
className={`${PILL} text-[0.8125rem]`}
onClick={() => setStatus('Sorting is not connected yet.')}
>
<ArrowUpDown aria-hidden="true" />
<span className="sr-only">Sort by</span> Code{' '}
<span aria-hidden="true">·</span> Ascending
</Button>
/>
</div>

<p
Expand Down
Loading