From 1d134d9f90bd5067bb5e1430c6006a2e77aae1e2 Mon Sep 17 00:00:00 2001 From: Rafal Enden Date: Thu, 31 Oct 2019 17:04:44 +0000 Subject: [PATCH] feat: Add Pagination component --- src/index.jsx | 3 + src/pagination/Pagination.jsx | 159 ++++++++++++++ .../__stories__/Pagination.stories.jsx | 23 ++ src/pagination/__tests__/Pagination.test.jsx | 203 ++++++++++++++++++ src/pagination/computeVisiblePieces.js | 71 ++++++ src/pagination/constants.js | 6 + 6 files changed, 465 insertions(+) create mode 100644 src/pagination/Pagination.jsx create mode 100644 src/pagination/__stories__/Pagination.stories.jsx create mode 100644 src/pagination/__tests__/Pagination.test.jsx create mode 100644 src/pagination/computeVisiblePieces.js create mode 100644 src/pagination/constants.js diff --git a/src/index.jsx b/src/index.jsx index 6a84abb2..24ff9dfc 100644 --- a/src/index.jsx +++ b/src/index.jsx @@ -63,5 +63,8 @@ export { default as FieldUneditable } from './forms/elements/FieldUneditable' export { default as Metadata } from './metadata/Metadata' export { default as MetadataItem } from './metadata/MetadataItem' +// Pagination +export { default as Pagination } from './pagination/Pagination' + // Status message export { default as StatusMessage } from './status-message/StatusMessage' diff --git a/src/pagination/Pagination.jsx b/src/pagination/Pagination.jsx new file mode 100644 index 00000000..b330a4a4 --- /dev/null +++ b/src/pagination/Pagination.jsx @@ -0,0 +1,159 @@ +import React from 'react' +import PropTypes from 'prop-types' +import styled from 'styled-components' +import { GREY_1, GREY_3, GREY_4, LINK_COLOUR, TEXT_COLOUR } from 'govuk-colours' +import { FONT_SIZE, MEDIA_QUERIES, SPACING } from '@govuk-react/constants' +import Link from '@govuk-react/link' + +import computeVisiblePieces from './computeVisiblePieces' + +import { + PAGINATION_PIECE_ELLIPSIS, + PAGINATION_PIECE_NEXT, + PAGINATION_PIECE_PAGE_NUMBER, + PAGINATION_PIECE_PREVIOUS, +} from './constants' + +const StyledNav = styled('nav')` + text-align: center; + line-height: 1; + display: flex; + justify-content: space-around; + padding: ${SPACING.SCALE_3}; + + ${MEDIA_QUERIES.TABLET} { + display: block; + } +` + +const StyledPaginationList = styled('ul')` + margin: 0; + list-style: none; +` + +const StyledPaginationPiece = styled('li')` + display: inline-block; + + & + & { + margin-left: ${SPACING.SCALE_1}; + } +` + +const StyledPaginationLink = styled(Link)` + font-weight: bold; + font-size: ${FONT_SIZE.SIZE_16}; + display: inline-block; + padding: ${SPACING.SCALE_1} ${SPACING.SCALE_3}; + background-color: ${GREY_4}; + line-height: 1.9em; + color: ${LINK_COLOUR}; + text-decoration: none; + + :hover { + background-color: ${GREY_3}; + } +` + +const StyledActivePaginationLink = styled(StyledPaginationLink)` + :link { + color: ${TEXT_COLOUR}; + background-color: transparent; + } +` + +const StyledPagesTruncation = styled('span')` + font-weight: bold; + font-size: ${FONT_SIZE.SIZE_16}; + display: inline-block; + padding: ${SPACING.SCALE_2}; + background-color: transparent; + line-height: ${FONT_SIZE.SIZE_24}; + color: ${GREY_1}; +` + +function Pagination({ totalPages, activePage, onPageClick, getPageUrl }) { + const visiblePieces = computeVisiblePieces(totalPages, activePage) + + if (totalPages === 1) { + return null + } + + return ( + + + {visiblePieces.map( + ({ type, pageNumber, isActive, isDisabled }, index) => { + const key = `${type}-${index}` + const onClick = (event) => onPageClick(pageNumber, event) + + const PageNumberLink = isActive + ? StyledActivePaginationLink + : StyledPaginationLink + + if (isDisabled) { + return null + } + + return ( + + {type === PAGINATION_PIECE_PREVIOUS && ( + + Previous + + )} + + {type === PAGINATION_PIECE_ELLIPSIS && ( + + … + + )} + + {type === PAGINATION_PIECE_PAGE_NUMBER && ( + + {pageNumber} + + )} + + {type === PAGINATION_PIECE_NEXT && ( + + Next + + )} + + ) + } + )} + + + ) +} + +Pagination.propTypes = { + totalPages: PropTypes.number.isRequired, + activePage: PropTypes.number, + onPageClick: PropTypes.func, + getPageUrl: PropTypes.func, +} + +Pagination.defaultProps = { + activePage: 1, + onPageClick: null, + getPageUrl: () => '#', +} + +export default Pagination diff --git a/src/pagination/__stories__/Pagination.stories.jsx b/src/pagination/__stories__/Pagination.stories.jsx new file mode 100644 index 00000000..864b130f --- /dev/null +++ b/src/pagination/__stories__/Pagination.stories.jsx @@ -0,0 +1,23 @@ +import React, { useState } from 'react' +import { storiesOf } from '@storybook/react' + +import Pagination from '../Pagination' + +const collectionStories = storiesOf('Pagination', module) + +const PaginationWithState = () => { + const [activePage, setActivePage] = useState(1) + + return ( + { + setActivePage(page) + event.preventDefault() + }} + /> + ) +} + +collectionStories.add('Default', () => ) diff --git a/src/pagination/__tests__/Pagination.test.jsx b/src/pagination/__tests__/Pagination.test.jsx new file mode 100644 index 00000000..92b27015 --- /dev/null +++ b/src/pagination/__tests__/Pagination.test.jsx @@ -0,0 +1,203 @@ +import React from 'react' +import { mount } from 'enzyme/build' +import Pagination from '../Pagination' + +describe('Pagination', () => { + let wrapper + + describe('when 7 pages are passed and the active page is 3', () => { + beforeAll(() => { + wrapper = mount() + }) + + test('should render the previous button', () => { + expect( + wrapper + .find('a') + .first() + .text() + ).toBe('Previous') + }) + + test('should render the next button', () => { + expect( + wrapper + .find('a') + .last() + .text() + ).toBe('Next') + }) + + test('should render all the pagination pieces', () => { + expect(wrapper.find('li')).toHaveLength(9) + }) + + test('should render the truncated page link', () => { + expect(wrapper.find('li span')).toHaveLength(1) + }) + + test('should render the active page', () => { + expect(wrapper.find('a[data-test="page-number-active"]').text()).toEqual( + '3' + ) + }) + }) + + describe('when 3 pages are passed and the active page is 2', () => { + beforeAll(() => { + wrapper = mount() + }) + + test('should render the previous button', () => { + expect( + wrapper + .find('a') + .first() + .text() + ).toBe('Previous') + }) + + test('should render the next button', () => { + expect( + wrapper + .find('a') + .last() + .text() + ).toBe('Next') + }) + + test('should render all the pagination pieces', () => { + expect(wrapper.find('li')).toHaveLength(5) + }) + + test('should render the active page', () => { + expect(wrapper.find('a[data-test="page-number-active"]').text()).toEqual( + '2' + ) + }) + }) + + describe('when just 1 page is passed', () => { + beforeAll(() => { + wrapper = mount() + }) + + test('should not render the component', () => { + expect(wrapper.find('nav').exists()).toBe(false) + }) + }) + + describe('when 2 pages are passed and the active page is page 1', () => { + beforeAll(() => { + wrapper = mount() + }) + + test('should not render the previous button', () => { + expect(wrapper.findWhere((a) => a.text() === 'Previous')).toHaveLength(0) + }) + + test('should render the next button', () => { + expect( + wrapper + .find('a') + .last() + .text() + ).toBe('Next') + }) + }) + + describe('when 2 pages are passed and the active page is page 2', () => { + beforeAll(() => { + wrapper = mount() + }) + + test('should not render the next button', () => { + expect(wrapper.findWhere((a) => a.text() === 'Next')).toHaveLength(0) + }) + + test('should render the previous button', () => { + expect( + wrapper + .find('a') + .first() + .text() + ).toBe('Previous') + }) + }) + + describe('when 1000 pages are passed and the active page is 99', () => { + beforeAll(() => { + wrapper = mount() + }) + + test('should render the previous button', () => { + expect( + wrapper + .find('a') + .first() + .text() + ).toBe('Previous') + }) + + test('should render the next button', () => { + expect( + wrapper + .find('a') + .last() + .text() + ).toBe('Next') + }) + + test('should render all the page links (including two truncated ones)', () => { + expect(wrapper.find('li')).toHaveLength(11) + }) + + test('should render two truncated page links', () => { + expect(wrapper.find('li span[data-test="ellipsis"]')).toHaveLength(2) + }) + + test('should render the active page', () => { + expect(wrapper.find('a[data-test="page-number-active"]').text()).toEqual( + '99' + ) + }) + }) + + describe('when 1000 pages are passed and the active page is 999', () => { + beforeAll(() => { + wrapper = mount() + }) + + test('should render the previous button', () => { + expect( + wrapper + .find('a') + .first() + .text() + ).toBe('Previous') + }) + + test('should render the next button', () => { + expect( + wrapper + .find('a') + .last() + .text() + ).toBe('Next') + }) + + test('should render all the page links', () => { + expect(wrapper.find('li')).toHaveLength(9) + }) + + test('should render one truncated page link', () => { + expect(wrapper.find('li span[data-test="ellipsis"]')).toHaveLength(1) + }) + + test('should render the active page', () => { + expect(wrapper.find('a[data-test="page-number-active"]').text()).toEqual( + '999' + ) + }) + }) +}) diff --git a/src/pagination/computeVisiblePieces.js b/src/pagination/computeVisiblePieces.js new file mode 100644 index 00000000..3a71a707 --- /dev/null +++ b/src/pagination/computeVisiblePieces.js @@ -0,0 +1,71 @@ +import { + DEFAULT_MAX_PAGE_NUMBER_LINKS, + PAGINATION_PIECE_ELLIPSIS, + PAGINATION_PIECE_NEXT, + PAGINATION_PIECE_PAGE_NUMBER, + PAGINATION_PIECE_PREVIOUS, +} from './constants' + +function computeVisiblePieces( + numberOfPages, + activePage, + maxPageNumbers = DEFAULT_MAX_PAGE_NUMBER_LINKS +) { + const visiblePieces = [] + let lowerLimit = activePage + let upperLimit = activePage + + visiblePieces.push({ + type: PAGINATION_PIECE_PREVIOUS, + pageNumber: Math.max(1, activePage - 1), + isDisabled: activePage === 1, + }) + + for (let i = 1; i < maxPageNumbers && i < numberOfPages; ) { + if (lowerLimit > 1) { + lowerLimit -= 1 + i += 1 + } + + if (i < maxPageNumbers && upperLimit < numberOfPages) { + upperLimit += 1 + i += 1 + } + } + + if (lowerLimit > 1) { + visiblePieces.push({ + type: PAGINATION_PIECE_PAGE_NUMBER, + pageNumber: 1, + isActive: activePage === 1, + }) + visiblePieces.push({ type: PAGINATION_PIECE_ELLIPSIS }) + } + + for (let i = lowerLimit; i <= upperLimit; i += 1) { + visiblePieces.push({ + type: PAGINATION_PIECE_PAGE_NUMBER, + pageNumber: i, + isActive: activePage === i, + }) + } + + if (activePage < numberOfPages - 2) { + visiblePieces.push({ type: PAGINATION_PIECE_ELLIPSIS }) + visiblePieces.push({ + type: PAGINATION_PIECE_PAGE_NUMBER, + pageNumber: numberOfPages, + isActive: activePage === numberOfPages, + }) + } + + visiblePieces.push({ + type: PAGINATION_PIECE_NEXT, + pageNumber: Math.min(numberOfPages, activePage + 1), + isDisabled: activePage === numberOfPages, + }) + + return visiblePieces +} + +export default computeVisiblePieces diff --git a/src/pagination/constants.js b/src/pagination/constants.js new file mode 100644 index 00000000..3d14061a --- /dev/null +++ b/src/pagination/constants.js @@ -0,0 +1,6 @@ +export const PAGINATION_PIECE_PREVIOUS = 'previous' +export const PAGINATION_PIECE_ELLIPSIS = 'ellipsis' +export const PAGINATION_PIECE_PAGE_NUMBER = 'page-number' +export const PAGINATION_PIECE_NEXT = 'next' + +export const DEFAULT_MAX_PAGE_NUMBER_LINKS = 5