Add pagination in list view

This commit is contained in:
Pierre Burgy 2017-04-11 11:34:59 +02:00
parent 3876aefd03
commit fedc7dd1e4
21 changed files with 515 additions and 141 deletions

View File

@ -16,6 +16,14 @@
"policies": []
}
},
{
"method": "GET",
"path": "/explorer/:model/count",
"handler": "ContentManager.count",
"config": {
"policies": []
}
},
{
"method": "GET",
"path": "/explorer/:model/:id",

View File

@ -16,19 +16,37 @@ module.exports = {
find: async(ctx) => {
const model = ctx.params.model;
const {
limit = 10,
skip = 0,
} = ctx.request.query;
const entries = await User
.find();
.find()
.limit(limit)
.limit(skip);
ctx.body = entries;
},
count: async(ctx) => {
const model = ctx.params.model;
const count = await User
.count();
ctx.body = {
count: Number(count)
};
},
findOne: async(ctx) => {
const model = ctx.params.model;
const id = ctx.params.id;
const entries = await User
.find({
id: id
id
});
ctx.body = entries;

View File

@ -0,0 +1,180 @@
/**
*
* Pagination
*
*/
import React from 'react';
import styles from './styles.scss';
class Pagination extends React.Component { // eslint-disable-line react/prefer-stateless-function
constructor(props) {
super(props);
this.state = {
progress: -1,
};
this.onGoPreviousPageClicked = this.onGoPreviousPageClicked.bind(this);
this.onGoNextPageClicked = this.onGoNextPageClicked.bind(this);
}
/**
* Check if the current page is the first one or not
*
* @returns {boolean}
*/
isFirstPage() {
return this.props.currentPage === 1;
}
/**
* Check if the
*
* @returns {boolean}
*/
needPreviousLinksDots() {
return this.props.currentPage >= 3;
}
needAfterLinksDots() {
return this.props.currentPage < this.lastPage() - 1;
}
/**
* Return the last page number
*
* @returns {number}
*/
lastPage() {
return Math.ceil(this.props.count / this.props.limitPerPage);
}
/**
* Check if the current page is the last one
*
* @returns {boolean}
*/
isLastPage() {
return this.props.currentPage === this.lastPage();
}
/**
* Triggered on previous page click.
*
* Prevent link default behavior and go to previous page.
*
* @param e {Object} Click event
*/
onGoPreviousPageClicked(e) {
e.preventDefault();
if (!this.isFirstPage()) {
this.props.goPreviousPage();
}
}
/**
* Triggered on next page click.
*
* Prevent link default behavior and go to next page.
*
* @param e {Object} Click event
*/
onGoNextPageClicked(e) {
e.preventDefault();
if (!this.isLastPage()) {
this.props.goNextPage();
}
}
render() {
// Init variables
let beforeLinksDots;
let afterLinksDots;
const linksOptions = [];
const dotsElem = (<li className={styles.navLi}><span>...</span></li>);
// Add active page link
linksOptions.push({
value: this.props.currentPage,
isActive: true,
onClick: (e) => {
e.preventDefault();
},
});
// Add previous page link
if (!this.isFirstPage()) {
linksOptions.unshift({
value: this.props.currentPage - 1,
isActive: false,
onClick: this.onGoPreviousPageClicked,
});
}
// Add next page link
if (!this.isLastPage()) {
linksOptions.push({
value: this.props.currentPage + 1,
isActive: false,
onClick: this.onGoNextPageClicked,
});
}
// Add previous link dots
if (this.needPreviousLinksDots()) {
beforeLinksDots = dotsElem;
}
// Add next link dots
if (this.needAfterLinksDots()) {
afterLinksDots = dotsElem;
}
// Generate links
const links = linksOptions.map((linksOption) => (
<li className={`${styles.navLi} ${linksOption.isActive && styles.navLiActive}`} key={linksOption.value}>
<a href disabled={linksOption.isActive} onClick={linksOption.onClick}>
{linksOption.value}
</a>
</li>
)
);
return (
<div className={styles.pagination}>
<a href
className={`
${styles.paginationNavigator}
${styles.paginationNavigatorPrevious}
${this.isFirstPage() && styles.paginationNavigatorDisabled}
`}
onClick={this.onGoPreviousPageClicked}
disabled={this.isFirstPage()}>
<i className="ion ion-chevron-left"></i>
</a>
<div className={styles.separator}></div>
<nav className={styles.nav}>
<ul className={styles.navUl}>
{ beforeLinksDots }
{links}
{ afterLinksDots }
</ul>
</nav>
<a href
className={`
${styles.paginationNavigator}
${styles.paginationNavigatorNext}
${this.isLastPage() && styles.paginationNavigatorDisabled}
`}
onClick={this.onGoNextPageClicked}
disabled={this.isLastPage()}>
<i className="ion ion-chevron-right"></i>
</a>
</div>
);
}
}
export default Pagination;

View File

@ -0,0 +1,78 @@
.pagination {
display: inline-flex;
flex-direction: row;
margin-top: 2rem;
border: 1px solid #DADBDC;
border-radius: 2.4rem;
}
.paginationNavigator {
background: #FFFFFF;
padding-top: 1rem;
padding-bottom: 1rem;
padding-left: 2rem;
padding-right: 2rem;
border-right: 1px solid #EFF3F6;
}
.paginationNavigatorDisabled {
color: #DADBDC;
&:hover,
&:focus {
color: #DADBDC;
}
}
.paginationNavigatorPrevious {
border-radius: 2.4rem 0 0 2.4rem;
border-right: 1px solid #EFF3F6;
}
.paginationNavigatorNext {
border-radius: 0 2.4rem 2.4rem 0;
border-left: 1px solid #EFF3F6;
}
.nav {
background: #FFFFFF;
}
.navUl {
margin: 0;
padding: 0;
height: 100%;
padding-left: 1rem;
padding-right: 1rem;
display: flex;
flex-direction: row;
}
.navLi {
padding-top: 1rem;
padding-left: 0.8rem;
padding-right: 0.8rem;
color: #465373;
border-bottom: 3px solid transparent;
a,
a:visited,
a:focus,
a:active {
text-decoration: none;
color: #465373;;
}
a:hover {
color: #215CEA;
}
}
.navLiActive {
border-bottom: 3px solid #215CEA;
a,
a:hover{
color: #000000 !important;
}
}

View File

@ -9,8 +9,6 @@ import React from 'react';
import TableHeader from 'components/TableHeader';
import TableRow from 'components/TableRow';
import { FormattedMessage } from 'react-intl';
import messages from './messages';
import styles from './styles.scss';
class Table extends React.Component { // eslint-disable-line react/prefer-stateless-function

View File

@ -1,13 +0,0 @@
/*
* Table Messages
*
* This contains all the text for the Table component.
*/
import { defineMessages } from 'react-intl';
export default defineMessages({
header: {
id: 'app.components.Table.header',
defaultMessage: 'This is the Table component !',
},
});

View File

@ -1,11 +0,0 @@
// import Table from '../index';
import expect from 'expect';
// import { shallow } from 'enzyme';
// import React from 'react';
describe('<Table />', () => {
it('Expect to have unit tests specified', () => {
expect(true).toEqual(false);
});
});

View File

@ -6,15 +6,11 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import messages from './messages';
import styles from './styles.scss';
class TableHeader extends React.Component { // eslint-disable-line react/prefer-stateless-function
render() {
const headers = this.props.headers.map((header, i) => {
return <th key={i}>{header.label}</th>
});
const headers = this.props.headers.map((header, i) => (<th key={i}>{header.label}</th>));
return (
<thead className={styles.tableHeader}>

View File

@ -1,13 +0,0 @@
/*
* TableHeader Messages
*
* This contains all the text for the TableHeader component.
*/
import { defineMessages } from 'react-intl';
export default defineMessages({
header: {
id: 'app.components.TableHeader.header',
defaultMessage: 'This is the TableHeader component !',
},
});

View File

@ -1,11 +0,0 @@
// import TableHeader from '../index';
import expect from 'expect';
// import { shallow } from 'enzyme';
// import React from 'react';
describe('<TableHeader />', () => {
it('Expect to have unit tests specified', () => {
expect(true).toEqual(false);
});
});

View File

@ -5,17 +5,13 @@
*/
import React from 'react';
import { Link } from 'react-router';
import { FormattedMessage } from 'react-intl';
import messages from './messages';
import styles from './styles.scss';
class TableRow extends React.Component { // eslint-disable-line react/prefer-stateless-function
render() {
const cells = this.props.headers.map((header, i) => {
return <td key={i} className={styles.tableRowCell}>{this.props.record[header.name]}</td>
});
const cells = this.props.headers.map((header, i) => (<td key={i} className={styles.tableRowCell}>{this.props.record[header.name]}</td>));
return (
<tr className={styles.tableRow}>

View File

@ -1,13 +0,0 @@
/*
* TableRow Messages
*
* This contains all the text for the TableRow component.
*/
import { defineMessages } from 'react-intl';
export default defineMessages({
header: {
id: 'app.components.TableRow.header',
defaultMessage: 'This is the TableRow component !',
},
});

View File

@ -1,11 +0,0 @@
// import TableRow from '../index';
import expect from 'expect';
// import { shallow } from 'enzyme';
// import React from 'react';
describe('<TableRow />', () => {
it('Expect to have unit tests specified', () => {
expect(true).toEqual(false);
});
});

View File

@ -7,7 +7,11 @@
import {
SET_CURRENT_MODEL_NAME,
LOAD_RECORDS,
LOADED_RECORDS
LOADED_RECORDS,
LOAD_COUNT,
LOADED_COUNT,
GO_NEXT_PAGE,
GO_PREVIOUS_PAGE,
} from './constants';
export function setCurrentModelName(modelName) {
@ -29,3 +33,28 @@ export function loadedRecord(records) {
records,
};
}
export function loadCount() {
return {
type: LOAD_COUNT,
};
}
export function loadedCount(count) {
return {
type: LOADED_COUNT,
count,
};
}
export function goNextPage() {
return {
type: GO_NEXT_PAGE,
};
}
export function goPreviousPage() {
return {
type: GO_PREVIOUS_PAGE,
};
}

View File

@ -5,5 +5,12 @@
*/
export const SET_CURRENT_MODEL_NAME = 'app/List/SET_CURRENT_MODEL_NAME';
export const LOAD_RECORDS = 'app/List/LOAD_RECORDS';
export const LOADED_RECORDS = 'app/List/LOADED_RECORDS';
export const LOAD_COUNT = 'app/List/LOAD_COUNT';
export const LOADED_COUNT = 'app/List/LOADED_COUNT';
export const GO_NEXT_PAGE = 'app/List/GO_NEXT_PAGE';
export const GO_PREVIOUS_PAGE = 'app/List/GO_PREVIOUS_PAGE';

View File

@ -8,21 +8,30 @@ import React from 'react';
import { connect } from 'react-redux';
import { createStructuredSelector } from 'reselect';
import { injectIntl } from 'react-intl';
import _ from 'lodash';
import Container from 'components/Container';
import Table from 'components/Table';
import _ from 'lodash';
import Pagination from 'components/Pagination';
import styles from './styles.scss';
import {
setCurrentModelName,
loadRecords,
loadCount,
goNextPage,
goPreviousPage,
} from './actions';
import {
makeSelectLoading,
makeSelectModelRecords,
makeSelectRecords,
makeSelectLoadingRecords,
makeSelectCurrentModelName,
makeSelectCount,
makeSelectCurrentPage,
makeSelectLimitPerPage,
makeSelectLoadingCount,
} from './selectors';
import {
@ -33,13 +42,14 @@ export class List extends React.Component { // eslint-disable-line react/prefer-
componentWillMount() {
this.props.setCurrentModelName(this.props.routeParams.slug.toLowerCase());
this.props.loadRecords();
this.props.loadCount();
}
render() {
const PluginHeader = this.props.exposedComponents.PluginHeader;
let content;
if (this.props.loading) {
if (this.props.loadingRecords) {
content = (
<div>
<p>Loading...</p>
@ -54,11 +64,10 @@ export class List extends React.Component { // eslint-disable-line react/prefer-
// Define table headers
const tableHeaders = _.map(displayedAttributes, (value, key) => ({
name: key,
label: key,
type: value.type,
})
);
name: key,
label: key,
type: value.type,
}));
content = (
<Table
@ -67,7 +76,7 @@ export class List extends React.Component { // eslint-disable-line react/prefer-
routeParams={this.props.routeParams}
headers={tableHeaders}
/>
)
);
}
return (
@ -84,6 +93,13 @@ export class List extends React.Component { // eslint-disable-line react/prefer-
<Container>
<p></p>
{content}
<Pagination
limitPerPage={this.props.limitPerPage}
currentPage={this.props.currentPage}
goNextPage={this.props.goNextPage}
goPreviousPage={this.props.goPreviousPage}
count={this.props.count}
/>
</Container>
</div>
</div>
@ -98,26 +114,45 @@ List.propTypes = {
React.PropTypes.bool,
]),
loadRecords: React.PropTypes.func,
loading: React.PropTypes.bool,
loadingRecords: React.PropTypes.bool,
models: React.PropTypes.oneOfType([
React.PropTypes.object,
React.PropTypes.bool,
]),
currentPage: React.PropTypes.number,
limitPerPage: React.PropTypes.number,
currentModelName: React.PropTypes.string,
goNextPage: React.PropTypes.func,
goPreviousPage: React.PropTypes.func,
};
function mapDispatchToProps(dispatch) {
return {
setCurrentModelName: (modelName) => dispatch(setCurrentModelName(modelName)),
loadRecords: () => dispatch(loadRecords()),
loadCount: () => dispatch(loadCount()),
goNextPage: () => {
dispatch(goNextPage());
dispatch(loadRecords());
dispatch(loadCount());
},
goPreviousPage: () => {
dispatch(goPreviousPage());
dispatch(loadRecords());
dispatch(loadCount());
},
dispatch,
};
}
const mapStateToProps = createStructuredSelector({
records: makeSelectModelRecords(),
loading: makeSelectLoading(),
records: makeSelectRecords(),
loadingRecords: makeSelectLoadingRecords(),
count: makeSelectCount(),
loadingCount: makeSelectLoadingCount(),
models: makeSelectModels(),
currentPage: makeSelectCurrentPage(),
limitPerPage: makeSelectLimitPerPage(),
currentModelName: makeSelectCurrentModelName(),
});

View File

@ -8,13 +8,21 @@ import { fromJS } from 'immutable';
import {
SET_CURRENT_MODEL_NAME,
LOAD_RECORDS,
LOADED_RECORDS
LOADED_RECORDS,
LOAD_COUNT,
LOADED_COUNT,
GO_NEXT_PAGE,
GO_PREVIOUS_PAGE,
} from './constants';
const initialState = fromJS({
currentModel: null,
loading: true,
loadingRecords: true,
records: false,
loadingCount: true,
count: false,
currentPage: 1,
limitPerPage: 10,
});
function listReducer(state = initialState, action) {
@ -24,11 +32,24 @@ function listReducer(state = initialState, action) {
.set('currentModelName', action.modelName);
case LOAD_RECORDS:
return state
.set('loading', true);
.set('loadingRecords', true);
case LOADED_RECORDS:
return state
.set('loading', false)
.set('loadingRecords', false)
.set('records', action.records);
case LOAD_COUNT:
return state
.set('loadingCount', true);
case LOADED_COUNT:
return state
.set('loadingCount', false)
.set('count', action.count);
case GO_NEXT_PAGE:
return state
.set('currentPage', state.get('currentPage') + 1);
case GO_PREVIOUS_PAGE:
return state
.set('currentPage', state.get('currentPage') - 1);
default:
return state;
}

View File

@ -1,20 +1,54 @@
import { takeLatest } from 'redux-saga';
import { put, select } from 'redux-saga/effects';
import { put, select, fork, call } from 'redux-saga/effects';
import request from 'utils/request';
import {
loadedRecord
loadedRecord,
loadedCount,
} from './actions';
import {
LOAD_RECORDS
LOAD_RECORDS,
LOAD_COUNT,
} from './constants';
import {
makeSelectCurrentModelName,
makeSelectLimitPerPage,
makeSelectCurrentPage,
} from './selectors';
export function* getRecords() {
const currentModel = yield select(makeSelectCurrentModelName());
const limitPerPage = yield select(makeSelectLimitPerPage());
const currentPage = yield select(makeSelectCurrentPage());
// Calculate the number of values to be skip
const skip = (currentPage - 1) * limitPerPage;
// Init `params` object
const params = {
skip,
limit: limitPerPage,
};
try {
const requestURL = `http://localhost:1337/content-manager/explorer/${currentModel}`;
// Call our request helper (see 'utils/request')
const data = yield call(request, requestURL, {
method: 'GET',
params,
});
yield put(loadedRecord(data));
} catch (err) {
console.error(err);
}
}
export function* getCount() {
const currentModel = yield select(makeSelectCurrentModelName());
try {
const opts = {
@ -22,19 +56,20 @@ export function* getRecords() {
mode: 'cors',
cache: 'default'
};
const response = yield fetch(`http://localhost:1337/content-manager/explorer/${currentModel}`, opts);
const response = yield fetch(`http://localhost:1337/content-manager/explorer/${currentModel}/count`, opts);
const data = yield response.json();
yield put(loadedRecord(data));
yield put(loadedCount(data.count));
} catch (err) {
console.error(err);
}
}
// Individual exports for testing
export function* defaultSaga() {
yield takeLatest(LOAD_RECORDS, getRecords);
yield fork(takeLatest, LOAD_RECORDS, getRecords);
yield fork(takeLatest, LOAD_COUNT, getCount);
}
// All sagas to be loaded

View File

@ -14,16 +14,38 @@ const selectListDomain = () => state => state.get('list');
* Default selector used by List
*/
const makeSelectModelRecords = () => createSelector(
const makeSelectRecords = () => createSelector(
selectListDomain(),
(substate) => {
return substate.get('records');
}
);
const makeSelectLoading = () => createSelector(
const makeSelectLoadingRecords = () => createSelector(
selectListDomain(),
(substate) => substate.get('loading')
(substate) => substate.get('loadingRecords')
);
const makeSelectCount = () => createSelector(
selectListDomain(),
(substate) => {
return substate.get('count');
}
);
const makeSelectLoadingCount = () => createSelector(
selectListDomain(),
(substate) => substate.get('loadingCount')
);
const makeSelectCurrentPage = () => createSelector(
selectListDomain(),
(substate) => substate.get('currentPage')
);
const makeSelectLimitPerPage = () => createSelector(
selectListDomain(),
(substate) => substate.get('limitPerPage')
);
const makeSelectCurrentModelName = () => createSelector(
@ -33,7 +55,11 @@ const makeSelectCurrentModelName = () => createSelector(
export {
selectListDomain,
makeSelectLoading,
makeSelectModelRecords,
makeSelectRecords,
makeSelectLoadingRecords,
makeSelectCount,
makeSelectLoadingCount,
makeSelectCurrentPage,
makeSelectLimitPerPage,
makeSelectCurrentModelName,
};

View File

@ -46,7 +46,7 @@ export class Single extends React.Component { // eslint-disable-line react/prefe
<ul>
{items}
</ul>
)
);
}
return (

View File

@ -16,22 +16,32 @@ function parseJSON(response) {
*
* @param {object} response A response from a network request
*
* @return {Promise} Returns either the response, or throws an error
* @return {object|undefined} Returns either the response, or throws an error
*/
function checkStatus(response) {
return new Promise((resolve) => {
if (response.status >= 200 && response.status < 300) {
return resolve(response);
}
if (response.status >= 200 && response.status < 300) {
return response;
}
return parseJSON(response)
.then((data) => {
const error = new Error(data.message || response.statusText);
error.data = data;
error.response = response;
throw error;
});
});
return parseJSON(response)
.then((responseFormatted) => {
const error = new Error(response.statusText);
error.response = response;
error.response.payload = responseFormatted;
throw error;
});
}
/**
* Format query params
*
* @param params
* @returns {string}
*/
function formatQueryParams(params) {
return Object.keys(params)
.map((k) => `${encodeURIComponent(k)}=${encodeURIComponent(params[k])}`)
.join('&');
}
/**
@ -40,20 +50,29 @@ function checkStatus(response) {
* @param {string} url The URL we want to request
* @param {object} [options] The options we want to pass to "fetch"
*
* @return {object} An object containing either "data" or "err"
* @return {object} The response data
*/
export default function request(url, options) {
// Default headers
const params = options || { };
const defaultHeaders = {
Accept: 'application/json',
const optionsObj = options || {};
// Set headers
optionsObj.headers = {
'Content-Type': 'application/json',
};
params.headers = params && params.headers ? params.headers : defaultHeaders;
return fetch(url, params)
// Add parameters to url
let urlFormatted = url;
if (optionsObj && optionsObj.params) {
const params = formatQueryParams(optionsObj.params);
urlFormatted = `${url}?${params}`;
}
// Stringify body object
if (optionsObj && optionsObj.body) {
optionsObj.body = JSON.stringify(optionsObj.body);
}
return fetch(urlFormatted, optionsObj)
.then(checkStatus)
.then(parseJSON)
.then((data) => ({ data }))
.catch((err) => ({ err }));
.then(parseJSON);
}