Skip to content

Commit

Permalink
NEW Add paginator component
Browse files Browse the repository at this point in the history
  • Loading branch information
emteknetnz committed Mar 5, 2025
1 parent e4616c2 commit 7e7c561
Show file tree
Hide file tree
Showing 11 changed files with 357 additions and 7 deletions.
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 {
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

0 comments on commit 7e7c561

Please sign in to comment.