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