...
 
Commits (5)
import React, { useState, Fragment } from 'react'
import PropTypes from 'prop-types'
import styled from 'styled-components'
import { th, override } from '@pubsweet/ui-toolkit'
import { Icon, H6 } from '..'
export const usePagination = (items = [], perPage = 10, startPage = 0) => {
const getLastPage = () => {
const floor = Math.floor(items.length / perPage)
return items.length % perPage ? floor : floor - 1
}
const [page, setPage] = useState(
Math.min(Math.max(0, startPage), getLastPage()),
)
const [itemsPerPage, setItemsPerPage] = useState(perPage)
const toFirst = () => {
setPage(0)
}
const toLast = () => {
setPage(getLastPage())
}
const changeItemsPerPage = e => {
setItemsPerPage(e.target.value)
setPage(0)
}
const nextPage = () => {
setPage(page =>
page * itemsPerPage + itemsPerPage < items.length ? page + 1 : page,
)
}
const prevPage = () => {
setPage(page => Math.max(0, page - 1))
}
return {
page,
toLast,
setPage,
toFirst,
prevPage,
nextPage,
itemsPerPage,
changeItemsPerPage,
maxItems: items.length,
hasMore: itemsPerPage * (page + 1) < items.length,
paginatedItems: items.slice(page * itemsPerPage, itemsPerPage * (page + 1)),
}
}
const Pagination = ({ items = [], startPage, itemsPerPage, children }) => {
const { paginatedItems, ...controls } = usePagination(
items,
itemsPerPage,
startPage,
)
const ui = (
<Root>
<StyledH6>Showing</StyledH6>
<TextInput
data-test-id="pagination-input"
onChange={controls.changeItemsPerPage}
value={controls.itemsPerPage}
/>
{controls.page !== 0 && (
<Fragment>
<Icon data-test-id="pagination-first" onClick={controls.toFirst}>
chevrons_left
</Icon>
<Icon data-test-id="pagination-prev" onClick={controls.prevPage}>
chevron_left
</Icon>
</Fragment>
)}
<StyledH6 data-test-id="pagination-label">{`${controls.page *
controls.itemsPerPage +
1} to ${
controls.hasMore
? controls.itemsPerPage * (controls.page + 1)
: controls.maxItems
} out of ${items.length}`}</StyledH6>
{controls.hasMore && (
<Fragment>
<Icon data-test-id="pagination-next" onClick={controls.nextPage}>
chevron_right
</Icon>
<Icon data-test-id="pagination-last" onClick={controls.toLast}>
chevrons_right
</Icon>
</Fragment>
)}
</Root>
)
return children({ ui, paginatedItems, controls })
}
Pagination.usePagination = usePagination
Pagination.defaultProps = {
startPage: 0,
itemsPerPage: 10,
}
Pagination.propTypes = {
/** Items to paginate. */
items: PropTypes.arrayOf(PropTypes.any).isRequired,
/** The starting page of the pagination. Will be set to first or last possible page if given out of bounds values. */
startPage: PropTypes.number,
/** How many items should a page have. */
itemsPerPage: PropTypes.number,
}
// @component
export default Pagination
// #region styles
const Root = styled.div`
align-items: center;
display: flex;
justify-content: center;
${override('ui.Pagination')};
`
const TextInput = styled.input`
margin: 0 ${th('gridUnit')};
width: calc(${th('gridUnit')} * 4);
height: calc(${th('gridUnit')} * 3);
text-align: center;
vertical-align: middle;
${override('ui.Pagination.Input')};
`
const StyledH6 = styled(H6)`
margin: 0;
`
// #endregion
### Pagination UI widget and the pagination results are not coupled.
```js
const items = Array.from({ length: 27 }, (v, i) => `item ${i + 1}`)
;<Pagination items={items}>
{({ ui, paginatedItems }) => (
<React.Fragment>
{ui}
<hr />
<ul>
{paginatedItems.map((item, index) => (
<li key={index}>{item}</li>
))}
</ul>
</React.Fragment>
)}
</Pagination>
```
### Usage with your own custom pagination UI (render props method).
```js
const items = Array.from({ length: 27 }, (v, i) => `item ${i + 1}`)
;<Pagination items={items}>
{({ paginatedItems, controls }) => (
<React.Fragment>
<button onClick={controls.prevPage}>prev</button>
<button onClick={controls.nextPage}>next</button>
<br />
<ul>
{paginatedItems.map((item, index) => (
<li key={index}>{item}</li>
))}
</ul>
</React.Fragment>
)}
</Pagination>
```
Using the usePagination hook. Show only 5 items per page.
```js
const items = Array.from({ length: 27 }, (v, i) => `item ${i + 1}`)
const ShowcaseComponent = () => {
const pagination = Pagination.usePagination(items, 5)
return (
<React.Fragment>
<button onClick={pagination.toFirst}>To first</button>
<button onClick={pagination.prevPage}>Prev</button>
<button onClick={pagination.nextPage}>Next</button>
<button onClick={pagination.toLast}>To last</button>
<ul>
{pagination.paginatedItems.map((item, index) => (
<li key={index}>{item}</li>
))}
</ul>
</React.Fragment>
)
}
;<ShowcaseComponent />
```
### Exposed controls to implement custom pagination UI widget.
```js
const items = Array.from({ length: 27 }, (v, i) => `item ${i + 1}`)
const ControlsShowcase = () => {
const controls = Pagination.usePagination(items)
return (
<React.Fragment>
<button onClick={controls.toFirst}>To first</button>
<button onClick={controls.prevPage}>Prev</button>
<button onClick={controls.nextPage}>Next</button>
<button onClick={controls.toLast}>To last</button>
<br />
{Object.entries(controls).map(([key, value]) => (
<React.Fragment key={key}>
<code>
{`${key} ${
typeof value !== 'function' ? ' - ' + value : ''
} - ${typeof value} `}
</code>
<br />
</React.Fragment>
))}
</React.Fragment>
)
}
;<ControlsShowcase />
```
import React from 'react'
import 'jest-dom/extend-expect'
import { ThemeProvider } from 'styled-components'
import { cleanup, fireEvent, render } from 'react-testing-library'
import Pagination from '../src/molecules/Pagination'
const items = Array.from({ length: 30 }, (v, i) => `item ${i + 1}`)
describe('Pagination', () => {
beforeEach(cleanup)
it('renders the correct number of items', () => {
const { paginatedItems } = setup({
items,
})
expect(paginatedItems).toEqual(items.slice(0, 10))
})
it('changes to number of items shown per page', () => {
const { getByTestId, childrenSpy } = setup({
items,
})
fireEvent.change(getByTestId('pagination-input'), {
target: { value: 3 },
})
expect(childrenSpy).toHaveBeenLastCalledWith(
expect.objectContaining({
paginatedItems: items.slice(0, 3),
controls: expect.objectContaining({
itemsPerPage: '3',
}),
}),
)
})
it('switches to the next page', () => {
const { getByTestId, childrenSpy } = setup({
items,
itemsPerPage: 5,
})
fireEvent.click(getByTestId('pagination-next'))
expect(childrenSpy).toHaveBeenLastCalledWith(
expect.objectContaining({
paginatedItems: items.slice(5, 10),
controls: expect.objectContaining({
page: 1,
}),
}),
)
})
it('switches back and forth between pages', () => {
const { getByTestId, childrenSpy } = setup({ items })
expect(childrenSpy).toHaveBeenLastCalledWith(
expect.objectContaining({
paginatedItems: items.slice(0, 10),
controls: expect.objectContaining({
page: 0,
}),
}),
)
fireEvent.click(getByTestId('pagination-next'))
expect(childrenSpy).toHaveBeenLastCalledWith(
expect.objectContaining({
paginatedItems: items.slice(10, 20),
controls: expect.objectContaining({
page: 1,
}),
}),
)
fireEvent.click(getByTestId('pagination-prev'))
expect(childrenSpy).toHaveBeenLastCalledWith(
expect.objectContaining({
paginatedItems: items.slice(0, 10),
controls: expect.objectContaining({
page: 0,
}),
}),
)
})
it('jumps to last and first pages', () => {
const { getByTestId, queryByTestId, childrenSpy } = setup({
items,
itemsPerPage: 7,
})
// prev and toFirst icons should be hidden while on first page
expect(queryByTestId('pagination-first')).toBeNull()
expect(queryByTestId('pagination-prev')).toBeNull()
expect(queryByTestId('pagination-last')).toBeInTheDocument()
expect(queryByTestId('pagination-next')).toBeInTheDocument()
fireEvent.click(getByTestId('pagination-last'))
// next and toLast icons should be hidden while on the last page
expect(queryByTestId('pagination-last')).toBeNull()
expect(queryByTestId('pagination-next')).toBeNull()
expect(queryByTestId('pagination-first')).toBeInTheDocument()
expect(queryByTestId('pagination-prev')).toBeInTheDocument()
// we should render only the last items
expect(childrenSpy).toHaveBeenLastCalledWith(
expect.objectContaining({
paginatedItems: items.slice(28),
controls: expect.objectContaining({
page: 4,
}),
}),
)
fireEvent.click(getByTestId('pagination-first'))
// we should render the first items
expect(childrenSpy).toHaveBeenLastCalledWith(
expect.objectContaining({
paginatedItems: items.slice(0, 7),
controls: expect.objectContaining({
page: 0,
}),
}),
)
})
it('sets starting page to first when given negative value', () => {
const { childrenSpy } = setup({ items, startPage: -2 })
expect(childrenSpy).toHaveBeenLastCalledWith(
expect.objectContaining({
paginatedItems: items.slice(0, 10),
controls: expect.objectContaining({
page: 0,
}),
}),
)
})
it('sets starting page to last when given an out of bounds value', () => {
const { childrenSpy } = setup({ items, startPage: 1000 })
expect(childrenSpy).toHaveBeenLastCalledWith(
expect.objectContaining({
paginatedItems: items.slice(20),
controls: expect.objectContaining({
page: 2,
}),
}),
)
})
})
function setup(props = {}) {
let renderArgs
const childrenSpy = jest.fn(paginationArgs => {
renderArgs = paginationArgs
return paginationArgs.ui
})
const utils = render(
<ThemeProvider theme={{}}>
<Pagination {...props}>{childrenSpy}</Pagination>
</ThemeProvider>,
)
return { ...utils, ...renderArgs, childrenSpy }
}