Skip to content
New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

NEW Add paginator component #1909

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion client/dist/js/bundle.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion client/dist/styles/bundle.css

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions client/src/boot/registerComponents.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import ToastsContainer from 'containers/ToastsContainer/ToastsContainer';
import ListboxField from 'components/ListboxField/ListboxField';
import SearchableDropdownField from 'components/SearchableDropdownField/SearchableDropdownField';
import SudoModePasswordField from 'components/SudoModePasswordField/SudoModePasswordField';
import Paginator from 'components/Paginator/Paginator';

export default () => {
Injector.component.registerMany({
Expand Down Expand Up @@ -108,5 +109,6 @@ export default () => {
ListboxField,
SearchableDropdownField,
SudoModePasswordField,
Paginator,
});
};
1 change: 1 addition & 0 deletions client/src/bundles/bundle.js
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ import 'expose-loader?exposes=withRouter!lib/withRouter';
import 'expose-loader?exposes=ssUrlLib!lib/urls';
import 'expose-loader?exposes=SearchableDropdownField!components/SearchableDropdownField/SearchableDropdownField';
import 'expose-loader?exposes=SudoModePasswordField!components/SudoModePasswordField/SudoModePasswordField';
import 'expose-loader?exposes=Paginator!components/Paginator/Paginator';

// Legacy CMS
import '../legacy/jquery.changetracker';
Expand Down
105 changes: 105 additions & 0 deletions client/src/components/Paginator/Paginator.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import React from 'react';
import PropTypes from 'prop-types';
import i18n from 'i18n';

function Paginator(props) {
// Note that props.page is 1-based i.e. the first page is 1, not 0
const totalPages = Math.ceil(props.totalItems / props.maxItemsPerPage);

/**
* Creates an array of <option> elements for the page selection dropdown.
* Each option represents a page number from 1 to totalPages.
*/
function createOptions() {
const options = [];
for (let page = 1; page <= totalPages; page++) {
options.push(<option key={page} value={page}>{page}</option>);
}
return options;
}

/**
* Handler for whena new page is selected
*/
function handleChangePage(page) {
props.onChangePage(page);
}

/**
* Handler for when the user selects a new page from the dropdown.
*/
function handleSelect(evt) {
const page = evt.target.value * 1;
handleChangePage(page);
}

/**
* Handler for when the user clicks the "Previous" button.
*/
function handlePrev() {
handleChangePage(props.currentPage - 1);
}

/**
* Handler for when the user clicks the "Next" button.
*/
function handleNext() {
handleChangePage(props.currentPage + 1);
}

/**
* Renders the page selection dropdown.
*/
function renderSelect() {
return <>
<select
value={props.currentPage}
onChange={(evt) => handleSelect(evt)}
>
{createOptions()}
</select> / {totalPages}
</>;
}

/**
* Renders the "Previous" button.
*/
function renderPrevButton() {
if (props.currentPage === 1) {
return null;
}
const label = i18n._t('Admin.PREVIOUS', 'Previous');
return <button type="button" onClick={() => handlePrev()}>{label}</button>;
}

/**
* Renders the "Next" button.
*/
function renderNextButton() {
if (props.currentPage === totalPages) {
return null;
}
const label = i18n._t('Admin.NEXT', 'Next');
return <button type="button" onClick={() => handleNext()}>{label}</button>;
}

// Render the paginator
return <div className="paginator-footer">
<div>
<div className="paginator-prev">{renderPrevButton()}</div>
<div className="paginator-page">{renderSelect()}</div>
<div className="paginator-next">{renderNextButton()}</div>
</div>
</div>;
}

Paginator.propTypes = {
totalItems: PropTypes.number.isRequired,
maxItemsPerPage: PropTypes.number.isRequired,
currentPage: PropTypes.number.isRequired,
onChangePage: PropTypes.func.isRequired,
};

export { Paginator as Component };

export default Paginator;
89 changes: 89 additions & 0 deletions client/src/components/Paginator/Paginator.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
.paginator-footer > div {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

display: flex;
}

.paginator-page {
flex: 1;
text-align: center;
margin-top: -3px;

// Replicate .form-control;
select {
height: calc(#{$line-height-base} + #{$spacer});
padding: $input-btn-padding-y $input-btn-padding-x;
line-height: $input-btn-line-height;
color: $input-color;
border: $input-btn-border-width solid $input-border-color;
background-color: $input-bg;
background-image: none;
text-align: center;
display: inline;

&:focus {
color: $input-focus-color;
background-color: $input-focus-bg;
border-color: $input-focus-border-color;
outline: 0;
box-shadow: $input-box-shadow, $input-focus-box-shadow;
}
}
}

.paginator-footer {
// approx width to allow for 1000's of images
width: 200px;
margin: 0 auto;
}

$paginator-button-width: 36px;
$paginator-button-height: 30px;

.paginator-prev button:before {
content: "'";
}

.paginator-next button:before {
content: "&";
}

.paginator-prev,
.paginator-next {
// hold space so pagination doesn't move around
width: $paginator-button-width;

button {
white-space: nowrap;
border: 0;
background: transparent;
width: $paginator-button-width;
height: $paginator-button-height;
position: relative;
border-radius: $btn-border-radius;
overflow: hidden;

&:before {
font-family: "silverstripe";
color: $text-muted;
width: $paginator-button-width;
height: $paginator-button-height;
background-color: $body-bg;
position: absolute;
top: 0;
left: 0;
padding: $input-btn-padding-y;
transition: all .2s ease-in-out;
font-size: $font-size-lg;
-webkit-font-smoothing: antialiased;
line-height: $line-height-base;
}

&:hover {
background-color: $gray-200;
border-color: transparent;

&:before {
background-color: $gray-200;
}
}
}
}
75 changes: 75 additions & 0 deletions client/src/components/Paginator/tests/Paginator-story.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { jsxDecorator } from 'storybook-addon-jsx';
import React from 'react';
import Paginator from 'components/Paginator/Paginator';

export default {
title: 'Admin/Paginator',
component: Paginator,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component: 'Generic data paginator. Component has no internal state and relies on parent component to manage it via props.',
},
canvas: {
sourceState: 'shown',
},
controls: {
sort: 'alpha',
}
}
},
decorators: [
jsxDecorator,
],
argTypes: {
totalItems: {
description: 'The total number of items to paginate.',
control: 'number',
type: {
required: true,
},
table: {
type: { summary: 'string' },
defaultValue: { summary: '' },
}
},
maxItemsPerPage: {
description: 'The maximum number of items per page.',
control: 'number',
type: {
required: true,
},
table: {
type: { summary: 'string' },
defaultValue: { summary: '' },
}
},
currentPage: {
description: 'The current page number.',
control: 'number',
type: {
required: true,
},
table: {
type: { summary: 'string' },
defaultValue: { summary: '' },
}
},
onChangePage: {
description: 'Event handler for when the page changes.',
table: {
type: { summary: 'string' },
defaultValue: { summary: '' },
}
}
}
};

export const _Paginator = (args) => <Paginator {...args} />;
_Paginator.args = {
totalItems: 15,
maxItemsPerPage: 10,
currentPage: 1,
onChangePage: () => null,
};
77 changes: 77 additions & 0 deletions client/src/components/Paginator/tests/Paginator-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/* global jest, expect, test */

import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { Component as Paginator } from '../Paginator';

function makeProps(obj = {}) {
return {
totalItems: 10,
maxItemsPerPage: 5,
currentPage: 1,
onChangePage: () => null,
...obj
};
}

test('Paginator renders options', () => {
let nextPage = null;
const onChangePage = jest.fn((page) => {
nextPage = page;
});
const { container } = render(<Paginator {...makeProps({
onChangePage,
})}
/>);
const select = container.querySelector('.paginator-page select');
const options = select.querySelectorAll('option');
expect(options.length).toBe(2);
expect(options[0].value).toBe('1');
expect(options[1].value).toBe('2');
fireEvent.change(select, { target: { value: '2' } });
expect(onChangePage).toHaveBeenCalled();
expect(nextPage).toBe(2);
});

test('Paginator renders next button when on first page and not prev button', () => {
let nextPage = null;
const onChangePage = jest.fn((page) => {
nextPage = page;
});
const { container } = render(<Paginator {...makeProps({
onChangePage,
})}
/>);
expect(container.querySelectorAll('.paginator-prev button').length).toBe(0);
expect(container.querySelector('.paginator-next button').innerHTML).toBe('Next');
fireEvent.click(screen.getByText('Next'));
expect(onChangePage).toHaveBeenCalled();
expect(nextPage).toBe(2);
});

test('Paginator renders prev button when not on first page and not next button', () => {
let nextPage = null;
const onChangePage = jest.fn((page) => {
nextPage = page;
});
const { container } = render(<Paginator {...makeProps({
currentPage: 2,
onChangePage,
})}
/>);
expect(container.querySelector('.paginator-prev button').innerHTML).toBe('Previous');
expect(container.querySelectorAll('.paginator-next button').length).toBe(0);
fireEvent.click(screen.getByText('Previous'));
expect(onChangePage).toHaveBeenCalled();
expect(nextPage).toBe(1);
});

test('Paginator renders button buttons when it needs to', () => {
const { container } = render(<Paginator {...makeProps({
currentPage: 2,
totalItems: 11,
})}
/>);
expect(container.querySelector('.paginator-prev button').innerHTML).toBe('Previous');
expect(container.querySelector('.paginator-next button').innerHTML).toBe('Next');
});
1 change: 1 addition & 0 deletions client/src/styles/bundle.scss
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@
@import "../components/PopoverOptionSet/PopoverOptionSet";
@import "../components/FileStatusIcon/FileStatusIcon";
@import "../components/SearchableDropdownField/SearchableDropdownField";
@import "../components/Paginator/Paginator";

// Layout and sections
@import "layout";
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@
"devDependencies": {
"@babel/runtime": "^7.20.0",
"@silverstripe/eslint-config": "^1.3.0",
"@silverstripe/webpack-config": "^3.0.0-alpha2",
"@silverstripe/webpack-config": "^3.0.0-alpha3",
"@storybook/addon-actions": "^7.0.18",
"@storybook/addon-controls": "^7.0.18",
"@storybook/addon-essentials": "^7.0.18",
Expand Down
Loading