From d30908113b80719d7f3a7603c20c59e811484163 Mon Sep 17 00:00:00 2001 From: Hendrik de Graaf Date: Mon, 31 Aug 2020 15:24:12 +0200 Subject: [PATCH] feat: introduce pagination component with unit-tests, docs and demo --- packages/widgets/i18n/en.pot | 17 ++ .../widgets/src/Pagination/PageControls.js | 68 ++++++++ packages/widgets/src/Pagination/PageSelect.js | 52 ++++++ .../widgets/src/Pagination/PageSizeSelect.js | 54 +++++++ .../widgets/src/Pagination/PageSummary.js | 75 +++++++++ packages/widgets/src/Pagination/Pagination.js | 151 ++++++++++++++++++ .../src/Pagination/Pagination.stories.js | 65 ++++++++ .../src/Pagination/__fixtures__/index.js | 20 +++ .../Pagination/__tests__/PageControls.test.js | 81 ++++++++++ .../Pagination/__tests__/PageSelect.test.js | 41 +++++ .../__tests__/PageSizeSelect.test.js | 30 ++++ .../Pagination/__tests__/PageSummary.test.js | 66 ++++++++ .../Pagination/__tests__/Pagination.test.js | 45 ++++++ packages/widgets/src/index.js | 1 + .../src/translate/__tests__/index.test.js | 10 ++ packages/widgets/src/translate/index.js | 4 +- 16 files changed, 778 insertions(+), 2 deletions(-) create mode 100644 packages/widgets/src/Pagination/PageControls.js create mode 100644 packages/widgets/src/Pagination/PageSelect.js create mode 100644 packages/widgets/src/Pagination/PageSizeSelect.js create mode 100644 packages/widgets/src/Pagination/PageSummary.js create mode 100644 packages/widgets/src/Pagination/Pagination.js create mode 100644 packages/widgets/src/Pagination/Pagination.stories.js create mode 100644 packages/widgets/src/Pagination/__fixtures__/index.js create mode 100644 packages/widgets/src/Pagination/__tests__/PageControls.test.js create mode 100644 packages/widgets/src/Pagination/__tests__/PageSelect.test.js create mode 100644 packages/widgets/src/Pagination/__tests__/PageSizeSelect.test.js create mode 100644 packages/widgets/src/Pagination/__tests__/PageSummary.test.js create mode 100644 packages/widgets/src/Pagination/__tests__/Pagination.test.js diff --git a/packages/widgets/i18n/en.pot b/packages/widgets/i18n/en.pot index a6c173c0d8..6fef54654f 100644 --- a/packages/widgets/i18n/en.pot +++ b/packages/widgets/i18n/en.pot @@ -58,3 +58,20 @@ msgstr "" msgid "Error: {{ ERRORMESSAGE }}" msgstr "" + +msgid "Next" +msgstr "" + +msgid "Page" +msgstr "" + +msgid "Items per page" +msgstr "" + +msgid "" +"Page {{page}} of {{pageCount}}, items {{firstItem}}-{{lastItem}} of " +"{{total}}" +msgstr "" + +msgid "Previous" +msgstr "" diff --git a/packages/widgets/src/Pagination/PageControls.js b/packages/widgets/src/Pagination/PageControls.js new file mode 100644 index 0000000000..68c4910107 --- /dev/null +++ b/packages/widgets/src/Pagination/PageControls.js @@ -0,0 +1,68 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' +import { Button } from '@dhis2/ui-core' +import { ChevronRight, ChevronLeft } from '@dhis2/ui-icons' +import { spacers } from '@dhis2/ui-constants' + +import translate from '../translate' + +const PageControls = ({ + dataTest, + onClick, + nextPageText, + page, + pageCount, + previousPageText, +}) => ( +
+ + + +
+) + +PageControls.propTypes = { + dataTest: propTypes.string.isRequired, + nextPageText: propTypes.oneOfType([propTypes.string, propTypes.func]) + .isRequired, + page: propTypes.number.isRequired, + pageCount: propTypes.number.isRequired, + previousPageText: propTypes.oneOfType([propTypes.string, propTypes.func]) + .isRequired, + onClick: propTypes.func.isRequired, +} + +export { PageControls } diff --git a/packages/widgets/src/Pagination/PageSelect.js b/packages/widgets/src/Pagination/PageSelect.js new file mode 100644 index 0000000000..3b9b352c97 --- /dev/null +++ b/packages/widgets/src/Pagination/PageSelect.js @@ -0,0 +1,52 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' +import { SingleSelect, SingleSelectOption } from '@dhis2/ui-core' +import { spacers } from '@dhis2/ui-constants' + +import translate from '../translate' + +const createAvailablePages = length => + Array.from({ length }, (_x, i) => (i + 1).toString()) + +const PageSelect = ({ + dataTest, + pageSelectText, + onChange, + page, + pageCount, +}) => ( +
+ onChange(parseInt(selected, 10))} + className="select" + dataTest={`${dataTest}-page-select`} + prefix={translate(pageSelectText)} + > + {createAvailablePages(pageCount).map(availablePage => ( + + ))} + + +
+) + +PageSelect.propTypes = { + dataTest: propTypes.string.isRequired, + page: propTypes.number.isRequired, + pageCount: propTypes.number.isRequired, + pageSelectText: propTypes.oneOfType([propTypes.string, propTypes.func]) + .isRequired, + onChange: propTypes.func.isRequired, +} + +export { PageSelect, createAvailablePages } diff --git a/packages/widgets/src/Pagination/PageSizeSelect.js b/packages/widgets/src/Pagination/PageSizeSelect.js new file mode 100644 index 0000000000..e10b5cf2d3 --- /dev/null +++ b/packages/widgets/src/Pagination/PageSizeSelect.js @@ -0,0 +1,54 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' +import { SingleSelect, SingleSelectOption } from '@dhis2/ui-core' +import { spacers } from '@dhis2/ui-constants' + +import translate from '../translate' + +const PageSizeSelect = ({ + dataTest, + pageSizeSelectText, + pageSize, + pageSizes, + onChange, +}) => ( +
+ onChange(parseInt(selected, 10))} + className="select" + dataTest={`${dataTest}-pagesize-select`} + prefix={translate(pageSizeSelectText)} + > + {pageSizes.map(length => ( + + ))} + + +
+) + +PageSizeSelect.propTypes = { + dataTest: propTypes.string.isRequired, + pageSize: propTypes.number.isRequired, + pageSizeSelectText: propTypes.oneOfType([propTypes.string, propTypes.func]) + .isRequired, + pageSizes: propTypes.arrayOf(propTypes.string).isRequired, + onChange: propTypes.func.isRequired, +} + +export { PageSizeSelect } diff --git a/packages/widgets/src/Pagination/PageSummary.js b/packages/widgets/src/Pagination/PageSummary.js new file mode 100644 index 0000000000..503ec4298e --- /dev/null +++ b/packages/widgets/src/Pagination/PageSummary.js @@ -0,0 +1,75 @@ +import React from 'react' +import propTypes from '@dhis2/prop-types' +import { colors, spacers } from '@dhis2/ui-constants' + +import translate from '../translate' + +const getItemRange = (page, pageSize, total) => { + let firstItem, lastItem + + if (total === 0) { + // if no items are found, the pager total is 0 + // and we want to force the first and last item to be 0 too + firstItem = 0 + lastItem = 0 + } else { + // page is 1-based + firstItem = (page - 1) * pageSize + 1 + lastItem = firstItem + pageSize - 1 + } + + if (lastItem > total) { + lastItem = total + } + + return { firstItem, lastItem } +} + +const PageSummary = ({ + dataTest, + page, + pageCount, + pageSize, + pageSummaryText, + total, +}) => { + const { firstItem, lastItem } = getItemRange(page, pageSize, total) + const summary = translate(pageSummaryText, { + page, + pageCount, + firstItem, + lastItem, + total, + }) + + return ( +
+ {summary} + +
+ ) +} + +PageSummary.propTypes = { + dataTest: propTypes.string.isRequired, + page: propTypes.number.isRequired, + pageCount: propTypes.number.isRequired, + pageSize: propTypes.number.isRequired, + pageSummaryText: propTypes.oneOfType([propTypes.string, propTypes.func]) + .isRequired, + total: propTypes.number.isRequired, +} + +export { PageSummary, getItemRange } diff --git a/packages/widgets/src/Pagination/Pagination.js b/packages/widgets/src/Pagination/Pagination.js new file mode 100644 index 0000000000..7dd1b76bc4 --- /dev/null +++ b/packages/widgets/src/Pagination/Pagination.js @@ -0,0 +1,151 @@ +import React from 'react' +import i18n from '@dhis2/d2-i18n' +import propTypes from '@dhis2/prop-types' +import cx from 'classnames' + +import { PageControls } from './PageControls' +import { PageSelect } from './PageSelect' +import { PageSizeSelect } from './PageSizeSelect.js' +import { PageSummary } from './PageSummary.js' + +/** + * @module + * @param {Pagination.PropTypes} props + * + * @returns {React.Component} + * + * @example import { Pagination } from @dhis2/ui + * @see Specification: {@link https://github.com/dhis2/design-system/blob/master/molecules/pagination.md|Design system} + * @see Live demo: {@link /demo/?path=/story/pagination--default|Storybook} + */ +const Pagination = ({ + className, + dataTest, + hidePageSizeSelect, + hidePageSelect, + page, + pageCount, + pageSize, + total, + pageSizes, + onPageChange, + onPageSizeChange, + nextPageText, + pageSelectText, + pageSizeSelectText, + pageSummaryText, + previousPageText, +}) => { + return ( +
+ {hidePageSizeSelect ? ( +
+ ) : ( + + )} + +
+ {!hidePageSelect && ( + + )} + +
+ +
+ ) +} + +Pagination.defaultProps = { + dataTest: 'dhis2-uiwidgets-pagination', + pageSizes: ['5', '10', '20', '30', '40', '50', '75', '100'], + nextPageText: () => i18n.t('Next'), + pageSelectText: () => i18n.t('Page'), + pageSizeSelectText: () => i18n.t('Items per page'), + pageSummaryText: interpolationObject => + i18n.t( + 'Page {{page}} of {{pageCount}}, items {{firstItem}}-{{lastItem}} of {{total}}', + interpolationObject + ), + previousPageText: () => i18n.t('Previous'), +} + +/** + * @typedef {Object} PropTypes + * @static + * + * @prop {number} page + * @prop {number} pageCount + * @prop {number} pageSize + * @prop {number} total + * @prop {function} onPageChange + * @prop {function} onPageSizeChange + * @prop {string} [dataTest="dhis2-uiwidgets-pagination"] + * @prop {bool} hidePageSelect + * @prop {bool} hidePageSizeSelect + * @prop {string|function} [nextPageText] + * @prop {string|function} [pageSelectText] + * @prop {string|function} [pageSizeSelectText] + * @prop {Array.} [pageSizes=['5', '10', '20', '30', '40', '50', '75', '100']] + * @prop {string|function} [pageSummaryText] + * @prop {string|function} [previousPageText] + */ +Pagination.propTypes = { + page: propTypes.number.isRequired, + pageCount: propTypes.number.isRequired, + pageSize: propTypes.number.isRequired, + total: propTypes.number.isRequired, + onPageChange: propTypes.func.isRequired, + onPageSizeChange: propTypes.func.isRequired, + className: propTypes.string, + dataTest: propTypes.string, + hidePageSelect: propTypes.bool, + hidePageSizeSelect: propTypes.bool, + nextPageText: propTypes.oneOfType([propTypes.string, propTypes.func]), + pageSelectText: propTypes.oneOfType([propTypes.string, propTypes.func]), + pageSizeSelectText: propTypes.oneOfType([propTypes.string, propTypes.func]), + pageSizes: propTypes.arrayOf(propTypes.string), + pageSummaryText: propTypes.oneOfType([propTypes.string, propTypes.func]), + previousPageText: propTypes.oneOfType([propTypes.string, propTypes.func]), +} + +export { Pagination } diff --git a/packages/widgets/src/Pagination/Pagination.stories.js b/packages/widgets/src/Pagination/Pagination.stories.js new file mode 100644 index 0000000000..095bb81eea --- /dev/null +++ b/packages/widgets/src/Pagination/Pagination.stories.js @@ -0,0 +1,65 @@ +import React from 'react' +import { Pagination } from './Pagination.js' +import * as pagers from './__fixtures__' + +export default { title: 'Pagination', component: Pagination } + +const logOnPageChange = page => { + console.log(`Now navigate to page ${page}...`) +} + +const logOnPageSizeChange = pageSize => { + console.log(`Now change page size to ${pageSize}...`) +} + +export const Default = () => ( + +) + +export const PagerAtFirstPage = () => ( + +) + +export const PagerAtLastPage = () => ( + +) + +export const WithoutPageSizeSelect = () => ( + +) + +export const WithoutGoToPageSelect = () => ( + +) + +export const WithoutAnySelect = () => ( + +) diff --git a/packages/widgets/src/Pagination/__fixtures__/index.js b/packages/widgets/src/Pagination/__fixtures__/index.js new file mode 100644 index 0000000000..8d50c9b277 --- /dev/null +++ b/packages/widgets/src/Pagination/__fixtures__/index.js @@ -0,0 +1,20 @@ +export const atFirstPage = { + page: 1, + pageCount: 21, + total: 1035, + pageSize: 50, +} + +export const atTenthPage = { + page: 10, + pageCount: 21, + total: 1035, + pageSize: 50, +} + +export const atLastPage = { + page: 21, + pageCount: 21, + total: 1035, + pageSize: 50, +} diff --git a/packages/widgets/src/Pagination/__tests__/PageControls.test.js b/packages/widgets/src/Pagination/__tests__/PageControls.test.js new file mode 100644 index 0000000000..b912a0a7de --- /dev/null +++ b/packages/widgets/src/Pagination/__tests__/PageControls.test.js @@ -0,0 +1,81 @@ +import React from 'react' +import { shallow } from 'enzyme' +import { PageControls } from '../PageControls' +import * as mockPagers from '../__fixtures__' + +describe('', () => { + const mockOnClick = jest.fn() + const props = { + dataTest: 'test', + onClick: mockOnClick, + nextPageText: 'Next', + previousPageText: 'Previous', + ...mockPagers.atTenthPage, + } + + beforeEach(() => { + mockOnClick.mockClear() + }) + + it('renders without errors', () => { + shallow() + }) + + it('disables no buttons on a page between first and last', () => { + const wrapper = shallow() + + expect( + wrapper.find('.button-previous').getElement().props.disabled + ).toEqual(false) + + expect( + wrapper.find('.button-next').getElement().props.disabled + ).toEqual(false) + }) + + it('disables the previous page button on the first page', () => { + const wrapper = shallow( + + ) + + expect( + wrapper.find('.button-previous').getElement().props.disabled + ).toEqual(true) + + expect( + wrapper.find('.button-next').getElement().props.disabled + ).toEqual(false) + }) + + it('disables the next page button on the last page', () => { + const wrapper = shallow( + + ) + + expect( + wrapper.find('.button-previous').getElement().props.disabled + ).toEqual(false) + + expect( + wrapper.find('.button-next').getElement().props.disabled + ).toEqual(true) + }) + + it('calls the onClick handler with the value for the next page when next is clicked', () => { + const wrapper = shallow() + + wrapper.find('.button-next').simulate('click') + + expect(mockOnClick).toHaveBeenCalledTimes(1) + expect(mockOnClick).toHaveBeenCalledWith(11) + }) + + it('calls the onClick handler with the value for the previous page when previous is clicked', () => { + const wrapper = shallow() + + wrapper.find('.button-previous').simulate('click') + + expect(mockOnClick).toHaveBeenCalledTimes(1) + expect(mockOnClick).toHaveBeenCalledWith(9) + }) +}) diff --git a/packages/widgets/src/Pagination/__tests__/PageSelect.test.js b/packages/widgets/src/Pagination/__tests__/PageSelect.test.js new file mode 100644 index 0000000000..ea99ef9af5 --- /dev/null +++ b/packages/widgets/src/Pagination/__tests__/PageSelect.test.js @@ -0,0 +1,41 @@ +import React from 'react' +import { shallow } from 'enzyme' +import { SingleSelect } from '@dhis2/ui-core' + +import { PageSelect, createAvailablePages } from '../PageSelect' +import * as mockPagers from '../__fixtures__' + +describe('', () => { + const mockOnSelect = jest.fn() + const props = { + dataTest: 'test', + onChange: mockOnSelect, + pageSelectText: 'Page', + ...mockPagers.atTenthPage, + } + + beforeEach(() => { + mockOnSelect.mockClear() + }) + + it('renders without errors', () => { + shallow() + }) + + it('calls the onSelect handler with the value of selected option', () => { + const wrapper = shallow() + + wrapper.find(SingleSelect).simulate('change', { selected: '10' }) + + expect(mockOnSelect).toHaveBeenCalledTimes(1) + expect(mockOnSelect).toHaveBeenCalledWith(10) + }) + + describe('createAvailablePages helper', () => { + it('produces an array of strings starting with "1" and ending with the "lenght"', () => { + const expected = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10'] + + expect(createAvailablePages(10)).toEqual(expected) + }) + }) +}) diff --git a/packages/widgets/src/Pagination/__tests__/PageSizeSelect.test.js b/packages/widgets/src/Pagination/__tests__/PageSizeSelect.test.js new file mode 100644 index 0000000000..294436df40 --- /dev/null +++ b/packages/widgets/src/Pagination/__tests__/PageSizeSelect.test.js @@ -0,0 +1,30 @@ +import React from 'react' +import { shallow } from 'enzyme' +import { SingleSelect } from '@dhis2/ui-core' + +import { PageSizeSelect } from '../PageSizeSelect' +import * as mockPagers from '../__fixtures__' + +describe('', () => { + const mockOnSelect = jest.fn() + const props = { + dataTest: 'test', + onChange: mockOnSelect, + pageSizes: ['5', '10', '20', '30', '40', '50', '75', '100'], + pageSizeSelectText: 'Page size', + ...mockPagers.atTenthPage, + } + + it('renders without errors', () => { + shallow() + }) + + it('calls the onSelect handler with the value of selected option', () => { + const wrapper = shallow() + + wrapper.find(SingleSelect).simulate('change', { selected: '10' }) + + expect(mockOnSelect).toHaveBeenCalledTimes(1) + expect(mockOnSelect).toHaveBeenCalledWith(10) + }) +}) diff --git a/packages/widgets/src/Pagination/__tests__/PageSummary.test.js b/packages/widgets/src/Pagination/__tests__/PageSummary.test.js new file mode 100644 index 0000000000..d30467878e --- /dev/null +++ b/packages/widgets/src/Pagination/__tests__/PageSummary.test.js @@ -0,0 +1,66 @@ +import React from 'react' +import { shallow } from 'enzyme' +import { PageSummary, getItemRange } from '../PageSummary' +import { Pagination } from '../Pagination' +import * as mockPagers from '../__fixtures__' + +describe('', () => { + const props = { + dataTest: 'test', + pageSummaryText: Pagination.defaultProps.pageSummaryText, + } + it('renders without errors', () => { + shallow() + }) + + it('displays the correct information for a first page', () => { + const wrapper = shallow( + + ) + const expectedString = 'Page 1 of 21, items 1-50 of 1035' + + expect(wrapper.find('span').text()).toEqual(expectedString) + }) + + it('displays the correct information for a last page', () => { + const wrapper = shallow( + + ) + const expectedString = 'Page 21 of 21, items 1001-1035 of 1035' + + expect(wrapper.find('span').text()).toEqual(expectedString) + }) + + it('displays the correct information for a page between first and last', () => { + const wrapper = shallow( + + ) + const expectedString = 'Page 10 of 21, items 451-500 of 1035' + + expect(wrapper.find('span').text()).toEqual(expectedString) + }) + + describe('getItemRange', () => { + it('calculates the firstItem and lastItem correctly', () => { + const { page, pageSize, total } = mockPagers.atTenthPage + const { firstItem, lastItem } = getItemRange(page, pageSize, total) + + expect(firstItem).toEqual(451) + expect(lastItem).toEqual(500) + }) + + it('returns 0 for firstItem and lastItem if the total is 0', () => { + const { firstItem, lastItem } = getItemRange(1, 50, 0) + + expect(firstItem).toEqual(0) + expect(lastItem).toEqual(0) + }) + + it('uses the total count as lastItem when the last page is reached', () => { + const { page, pageSize, total } = mockPagers.atLastPage + const { lastItem } = getItemRange(page, pageSize, total) + + expect(lastItem).toEqual(total) + }) + }) +}) diff --git a/packages/widgets/src/Pagination/__tests__/Pagination.test.js b/packages/widgets/src/Pagination/__tests__/Pagination.test.js new file mode 100644 index 0000000000..8e0ef93e7c --- /dev/null +++ b/packages/widgets/src/Pagination/__tests__/Pagination.test.js @@ -0,0 +1,45 @@ +import React from 'react' +import { shallow } from 'enzyme' +import { Pagination } from '../Pagination' +import { PageSelect } from '../PageSelect' +import { PageSizeSelect } from '../PageSizeSelect' + +import * as mockPagers from '../__fixtures__' + +describe('', () => { + const props = { + ...mockPagers.atTenthPage, + onPageChange: () => {}, + onPageSizeChange: () => {}, + } + it('renders without errors', () => { + shallow() + }) + + it('renders a PageSelect and PageSizeSelect by default', () => { + const wrapper = shallow() + + expect(wrapper.find(PageSelect).length).toEqual(1) + expect(wrapper.find(PageSizeSelect).length).toEqual(1) + }) + it('renders without a PageSelect when hidePageSelect is true', () => { + const wrapper = shallow() + + expect(wrapper.find(PageSelect).length).toEqual(0) + expect(wrapper.find(PageSizeSelect).length).toEqual(1) + }) + it('renders without a PageSizeSelect when hidePageSizeSelect is true', () => { + const wrapper = shallow() + + expect(wrapper.find(PageSelect).length).toEqual(1) + expect(wrapper.find(PageSizeSelect).length).toEqual(0) + }) + it('renders without PageSelect and PageSizeSelect when both are true', () => { + const wrapper = shallow( + + ) + + expect(wrapper.find(PageSelect).length).toEqual(0) + expect(wrapper.find(PageSizeSelect).length).toEqual(0) + }) +}) diff --git a/packages/widgets/src/index.js b/packages/widgets/src/index.js index 95d59460de..bf4c12d073 100755 --- a/packages/widgets/src/index.js +++ b/packages/widgets/src/index.js @@ -7,6 +7,7 @@ export { FieldGroup } from './FieldGroup/FieldGroup.js' export { FileInputField } from './FileInputField/FileInputField.js' export { InputField } from './InputField/InputField.js' export { MultiSelectField } from './MultiSelectField/MultiSelectField.js' +export { Pagination } from './Pagination/Pagination.js' export { SingleSelectField } from './SingleSelectField/SingleSelectField.js' export { SwitchField } from './SwitchField/SwitchField.js' export { TextAreaField } from './TextAreaField/TextAreaField.js' diff --git a/packages/widgets/src/translate/__tests__/index.test.js b/packages/widgets/src/translate/__tests__/index.test.js index a58a8cb385..a5d4ea355b 100644 --- a/packages/widgets/src/translate/__tests__/index.test.js +++ b/packages/widgets/src/translate/__tests__/index.test.js @@ -9,6 +9,16 @@ describe('translate', () => { expect(result).toBe('translated string') }) + it('should pass the interpolationObject as an argument to the prop function', () => { + const spy = jest.fn(() => 'translated string') + const interpolationObject = { name: 'Test' } + + translate(spy, interpolationObject) + + expect(spy).toHaveBeenCalled() + expect(spy).toHaveBeenCalledWith(interpolationObject) + }) + it('should return prop as is if it is not a function', () => { const result = translate('just a string') diff --git a/packages/widgets/src/translate/index.js b/packages/widgets/src/translate/index.js index fa5b9a46e7..a49d9d09cc 100644 --- a/packages/widgets/src/translate/index.js +++ b/packages/widgets/src/translate/index.js @@ -8,9 +8,9 @@ * render, we can be certain that i18n will have initialized. */ -const translate = prop => { +const translate = (prop, interpolationObject) => { if (typeof prop === 'function') { - return prop() + return prop(interpolationObject) } return prop