Skip to content

Commit 682e787

Browse files
authored
Fix data table pagination (#5059)
* Data table pagination add re-render tests highlighting issues with component state for total pages and current page * add use effect to update component state if we see that initial state has changed * do not use use effect * only change when defaults change * add more test cases * also change table content * add test coverage for onchange * provide default page size * appease types * fix corner case * Add changeset
1 parent 2651510 commit 682e787

File tree

4 files changed

+179
-9
lines changed

4 files changed

+179
-9
lines changed

.changeset/dry-pens-pay.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@primer/react': major
3+
---
4+
5+
Fixes negative and invalid pages in data table pagination on re-render.

packages/react/src/DataTable/DataTable.stories.tsx

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,11 @@ export const Playground = (args: DataTableProps<UniqueRow> & ColWidthArgTypes) =
195195

196196
const align = args.align as CellAlignment
197197

198+
const [pageIndex, setPageIndex] = React.useState(0)
199+
const start = pageIndex * parseInt(args.pageSize, 10)
200+
const end = start + parseInt(args.pageSize, 10)
201+
const rows = data.slice(start, end)
202+
198203
return (
199204
<Table.Container>
200205
<Table.Title as="h2" id="repositories">
@@ -207,7 +212,7 @@ export const Playground = (args: DataTableProps<UniqueRow> & ColWidthArgTypes) =
207212
{...args}
208213
aria-labelledby="repositories"
209214
aria-describedby="repositories-subtitle"
210-
data={data}
215+
data={rows}
211216
columns={[
212217
{
213218
header: 'Repository',
@@ -276,12 +281,22 @@ export const Playground = (args: DataTableProps<UniqueRow> & ColWidthArgTypes) =
276281
},
277282
]}
278283
/>
284+
<Table.Pagination
285+
aria-label="Pagination for Repositories"
286+
pageSize={parseInt(args.pageSize, 10)}
287+
totalCount={data.length}
288+
onChange={({pageIndex}) => {
289+
setPageIndex(pageIndex)
290+
}}
291+
defaultPageIndex={parseInt(args.defaultPageIndex, 10)}
292+
/>
279293
</Table.Container>
280294
)
281295
}
282296

283297
Playground.args = {
284298
cellPadding: 'normal',
299+
pageSize: 5,
285300
}
286301

287302
Playground.argTypes = {
@@ -318,6 +333,18 @@ Playground.argTypes = {
318333
disable: true,
319334
},
320335
},
336+
pageSize: {
337+
control: {
338+
defaultValue: 5,
339+
type: 'number',
340+
min: 1,
341+
},
342+
},
343+
defaultPageIndex: {
344+
control: {
345+
type: 'number',
346+
},
347+
},
321348
cellPadding: {
322349
control: {
323350
type: 'radio',

packages/react/src/DataTable/Pagination.tsx

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -192,9 +192,15 @@ export function Pagination({
192192
totalCount,
193193
})
194194
const truncatedPageCount = pageCount > 2 ? Math.min(pageCount - 2, MAX_TRUNCATED_STEP_COUNT) : 0
195-
const [offsetStartIndex, setOffsetStartIndex] = useState(() => {
196-
return getDefaultOffsetStartIndex(pageIndex, pageCount, truncatedPageCount)
197-
})
195+
const defaultOffset = getDefaultOffsetStartIndex(pageIndex, pageCount, truncatedPageCount)
196+
const [defaultOffsetStartIndex, setDefaultOffsetStartIndex] = useState(defaultOffset)
197+
const [offsetStartIndex, setOffsetStartIndex] = useState(defaultOffsetStartIndex)
198+
199+
if (defaultOffsetStartIndex !== defaultOffset) {
200+
setOffsetStartIndex(defaultOffset)
201+
setDefaultOffsetStartIndex(defaultOffset)
202+
}
203+
198204
const offsetEndIndex = offsetStartIndex + truncatedPageCount - 1
199205
const hasLeadingTruncation = offsetStartIndex >= 2
200206
const hasTrailingTruncation = pageCount - 1 - offsetEndIndex > 1
@@ -333,15 +339,15 @@ function getDefaultOffsetStartIndex(pageIndex: number, pageCount: number, trunca
333339
// When the current page is closer to the end of the list than the beginning
334340
if (pageIndex > pageCount - 1 - pageIndex) {
335341
if (pageCount - 1 - pageIndex >= truncatedPageCount) {
336-
return pageIndex - 3
342+
return Math.max(pageIndex - 3, 1)
337343
}
338-
return pageCount - 1 - truncatedPageCount
344+
return Math.max(pageCount - 1 - truncatedPageCount, 1)
339345
}
340346

341347
// When the current page is closer to the beginning of the list than the end
342348
if (pageIndex < pageCount - 1 - pageIndex) {
343349
if (pageIndex >= truncatedPageCount) {
344-
return pageIndex - 3
350+
return Math.max(pageIndex - 3, 1)
345351
}
346352
return 1
347353
}
@@ -350,7 +356,7 @@ function getDefaultOffsetStartIndex(pageIndex: number, pageCount: number, trunca
350356
if (pageIndex < truncatedPageCount) {
351357
return pageIndex
352358
}
353-
return pageIndex - 3
359+
return Math.max(pageIndex - 3, 1)
354360
}
355361

356362
type RangeProps = {
@@ -491,7 +497,7 @@ type PaginationResult = {
491497
function usePagination(config: PaginationConfig): PaginationResult {
492498
const {defaultPageIndex, onChange, pageSize, totalCount} = config
493499
const pageCount = Math.ceil(totalCount / pageSize)
494-
const [pageIndex, setPageIndex] = useState(() => {
500+
const [defaultIndex, setDefaultIndex] = useState(() => {
495501
if (defaultPageIndex !== undefined) {
496502
if (defaultPageIndex >= 0 && defaultPageIndex < pageCount) {
497503
return defaultPageIndex
@@ -510,6 +516,13 @@ function usePagination(config: PaginationConfig): PaginationResult {
510516

511517
return 0
512518
})
519+
const [pageIndex, setPageIndex] = useState(defaultIndex)
520+
const validDefaultPageCount = defaultPageIndex !== undefined && defaultPageIndex >= 0 && defaultPageIndex < pageCount
521+
if (validDefaultPageCount && defaultIndex !== defaultPageIndex) {
522+
setDefaultIndex(defaultPageIndex)
523+
setPageIndex(defaultPageIndex)
524+
onChange?.({pageIndex: defaultPageIndex})
525+
}
513526
const pageStart = pageIndex * pageSize
514527
const pageEnd = Math.min(pageIndex * pageSize + pageSize, totalCount - 1)
515528
const hasNextPage = pageIndex + 1 < pageCount

packages/react/src/DataTable/__tests__/Pagination.test.tsx

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,27 @@ describe('Table.Pagination', () => {
8484
await user.click(getPreviousPage())
8585
expect(onChange).not.toHaveBeenCalled()
8686
})
87+
88+
it('should rerender many pages correctly', async () => {
89+
const onChange = jest.fn()
90+
91+
const {rerender} = render(
92+
<Pagination aria-label="Test label" onChange={onChange} defaultPageIndex={0} pageSize={25} totalCount={25} />,
93+
)
94+
expect(getPages()).toHaveLength(1)
95+
expect(getCurrentPage()).toEqual(getPage(0))
96+
expect(getPageRange()).toEqual('1 through 25 of 25')
97+
98+
rerender(
99+
<Pagination onChange={onChange} aria-label="Test label" defaultPageIndex={2} pageSize={5} totalCount={300} />,
100+
)
101+
expect(getPageRange()).toEqual('11 through 15 of 300')
102+
expect(getCurrentPage()).toEqual(getPage(2))
103+
expect(getInvalidPages()).toHaveLength(0)
104+
expect(onChange).toHaveBeenCalledWith({
105+
pageIndex: 2,
106+
})
107+
})
87108
})
88109

89110
describe('with two pages', () => {
@@ -114,6 +135,32 @@ describe('Table.Pagination', () => {
114135
expect(getPageRange()).toEqual('1 through 25 of 50')
115136
})
116137

138+
it('should rerender pager with correct page highlighted when clicking on pages and defaultPageIndex set', async () => {
139+
const user = userEvent.setup()
140+
const onChange = jest.fn()
141+
142+
render(
143+
<Pagination aria-label="Test label" onChange={onChange} defaultPageIndex={3} pageSize={25} totalCount={200} />,
144+
)
145+
146+
expect(getPageRange()).toEqual('76 through 100 of 200')
147+
expect(getCurrentPage()).toEqual(getPage(3))
148+
149+
await user.click(getPage(1))
150+
expect(onChange).toHaveBeenCalledWith({
151+
pageIndex: 1,
152+
})
153+
expect(getPageRange()).toEqual('26 through 50 of 200')
154+
expect(getCurrentPage()).toEqual(getPage(1))
155+
156+
await user.click(getPage(0))
157+
expect(onChange).toHaveBeenCalledWith({
158+
pageIndex: 0,
159+
})
160+
expect(getPageRange()).toEqual('1 through 25 of 200')
161+
expect(getCurrentPage()).toEqual(getPage(0))
162+
})
163+
117164
it('should call `onChange` when using the keyboard to interact with pages', async () => {
118165
const user = userEvent.setup()
119166
const onChange = jest.fn()
@@ -165,6 +212,31 @@ describe('Table.Pagination', () => {
165212
})
166213
})
167214

215+
it('should rerender pager with correct page highlighted when clicking on previous or next and defaultPageIndex set', async () => {
216+
const user = userEvent.setup()
217+
const onChange = jest.fn()
218+
219+
render(
220+
<Pagination aria-label="Test label" onChange={onChange} defaultPageIndex={3} pageSize={25} totalCount={200} />,
221+
)
222+
223+
expect(getPageRange()).toEqual('76 through 100 of 200')
224+
225+
await user.click(getNextPage())
226+
expect(onChange).toHaveBeenCalledWith({
227+
pageIndex: 4,
228+
})
229+
expect(getPageRange()).toEqual('101 through 125 of 200')
230+
expect(getCurrentPage()).toEqual(getPage(4))
231+
232+
await user.click(getPreviousPage())
233+
expect(onChange).toHaveBeenCalledWith({
234+
pageIndex: 3,
235+
})
236+
expect(getPageRange()).toEqual('76 through 100 of 200')
237+
expect(getCurrentPage()).toEqual(getPage(3))
238+
})
239+
168240
it('should call `onChange` when using the keyboard to interact with previous or next', async () => {
169241
const user = userEvent.setup()
170242
const onChange = jest.fn()
@@ -197,6 +269,27 @@ describe('Table.Pagination', () => {
197269
pageIndex: 0,
198270
})
199271
})
272+
273+
it('should rerender many pages correctly', async () => {
274+
const onChange = jest.fn()
275+
276+
const {rerender} = render(
277+
<Pagination aria-label="Test label" onChange={onChange} defaultPageIndex={1} pageSize={25} totalCount={50} />,
278+
)
279+
expect(getPages()).toHaveLength(2)
280+
expect(getCurrentPage()).toEqual(getPage(1))
281+
expect(getPageRange()).toEqual('26 through 50 of 50')
282+
283+
rerender(
284+
<Pagination aria-label="Test label" onChange={onChange} defaultPageIndex={0} pageSize={5} totalCount={300} />,
285+
)
286+
expect(getPageRange()).toEqual('1 through 5 of 300')
287+
expect(getCurrentPage()).toEqual(getPage(0))
288+
expect(getInvalidPages()).toHaveLength(0)
289+
expect(onChange).toHaveBeenCalledWith({
290+
pageIndex: 0,
291+
})
292+
})
200293
})
201294

202295
describe('with three or more pages', () => {
@@ -242,6 +335,34 @@ describe('Table.Pagination', () => {
242335
expect(pages).toHaveLength(7)
243336
})
244337
})
338+
339+
it('should rerender many pages correctly', async () => {
340+
const onChange = jest.fn()
341+
const {rerender} = render(
342+
<Pagination aria-label="Test label" onChange={onChange} defaultPageIndex={1} pageSize={10} totalCount={1000} />,
343+
)
344+
expect(getPages()).toHaveLength(8)
345+
expect(getCurrentPage()).toEqual(getPage(1))
346+
expect(getPageRange()).toEqual('11 through 20 of 1000')
347+
348+
rerender(
349+
<Pagination aria-label="Test label" onChange={onChange} defaultPageIndex={0} pageSize={5} totalCount={300} />,
350+
)
351+
expect(getPageRange()).toEqual('1 through 5 of 300')
352+
expect(getFirstPage()).toEqual(getCurrentPage())
353+
expect(getInvalidPages()).toHaveLength(0)
354+
expect(onChange).toHaveBeenCalledWith({
355+
pageIndex: 0,
356+
})
357+
})
358+
359+
it('when rendering 3 pages and the second page is selected we should render a page number not ...', async () => {
360+
const onChange = jest.fn()
361+
render(<Pagination aria-label="Test label" onChange={onChange} defaultPageIndex={1} pageSize={2} totalCount={6} />)
362+
expect(getPageRange()).toEqual('3 through 4 of 6')
363+
expect(getCurrentPage()).toEqual(getPage(1))
364+
expect(getInvalidPages()).toHaveLength(0)
365+
})
245366
})
246367

247368
function getPages() {
@@ -288,6 +409,10 @@ function getLastPage() {
288409
return pages[pages.length - 1]
289410
}
290411

412+
function getInvalidPages() {
413+
return getPages().filter(p => p.textContent?.match(/Page\s-/g) || p.textContent?.match(/Page\s0$/g))
414+
}
415+
291416
function getPageRange() {
292417
const element = document.querySelector('.TablePaginationRange')
293418
if (element && element.textContent) {

0 commit comments

Comments
 (0)