feat(client): pagination on timeline (#37569)

* Implemented pagination on timeline

* Made requested change and removed outline from button

* fix: PropTypes and off-by-one error

* Keep buttons centered in all cases and give fixed height timeline table rows to prevent jerk while changing pages

* First and last page navigation and margin issue fix.

* Explicitly importing specific lodash functions

* Refactored timeline pagination into a separate file.

* Refactored timeline to have total Pages as prop and made text corrections.

* Added proptypes for total pages

* made changes to setState call for lastPage

* Made a11y changes
This commit is contained in:
Mathew Joseph
2019-11-24 01:36:13 +05:30
committed by Randell Dawson
parent 5ea745d61f
commit 174af7fa66
3 changed files with 212 additions and 17 deletions

View File

@ -1,13 +1,16 @@
import React, { Component } from 'react'; import React, { Component, useMemo } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import format from 'date-fns/format'; import format from 'date-fns/format';
import { find, reverse, sortBy } from 'lodash'; import { find, reverse, sortBy } from 'lodash';
import { Button, Modal, Table } from '@freecodecamp/react-bootstrap'; import { Button, Modal, Table } from '@freecodecamp/react-bootstrap';
import { Link, useStaticQuery, graphql } from 'gatsby'; import { Link, useStaticQuery, graphql } from 'gatsby';
import TimelinePagination from './TimelinePagination';
import { FullWidthRow } from '../../helpers'; import { FullWidthRow } from '../../helpers';
import SolutionViewer from '../../settings/SolutionViewer'; import SolutionViewer from '../../settings/SolutionViewer';
import { challengeTypes } from '../../../../utils/challengeTypes'; import { challengeTypes } from '../../../../utils/challengeTypes';
// Items per page in timeline.
const ITEMS_PER_PAGE = 15;
const propTypes = { const propTypes = {
completedMap: PropTypes.arrayOf( completedMap: PropTypes.arrayOf(
@ -24,13 +27,30 @@ const propTypes = {
) )
}) })
), ),
username: PropTypes.string
};
const innerPropTypes = {
...propTypes,
idToNameMap: PropTypes.objectOf( idToNameMap: PropTypes.objectOf(
PropTypes.shape({ PropTypes.shape({
challengePath: PropTypes.string, challengePath: PropTypes.string,
challengeTitle: PropTypes.string challengeTitle: PropTypes.string
}) })
), ).isRequired,
username: PropTypes.string sortedTimeline: PropTypes.arrayOf(
PropTypes.shape({
id: PropTypes.string,
completedDate: PropTypes.number,
files: PropTypes.arrayOf(
PropTypes.shape({
ext: PropTypes.string,
contents: PropTypes.string
})
)
})
).isRequired,
totalPages: PropTypes.number.isRequired
}; };
class TimelineInner extends Component { class TimelineInner extends Component {
@ -39,12 +59,17 @@ class TimelineInner extends Component {
this.state = { this.state = {
solutionToView: null, solutionToView: null,
solutionOpen: false solutionOpen: false,
pageNo: 1
}; };
this.closeSolution = this.closeSolution.bind(this); this.closeSolution = this.closeSolution.bind(this);
this.renderCompletion = this.renderCompletion.bind(this); this.renderCompletion = this.renderCompletion.bind(this);
this.viewSolution = this.viewSolution.bind(this); this.viewSolution = this.viewSolution.bind(this);
this.firstPage = this.firstPage.bind(this);
this.prevPage = this.prevPage.bind(this);
this.nextPage = this.nextPage.bind(this);
this.lastPage = this.lastPage.bind(this);
} }
renderCompletion(completed) { renderCompletion(completed) {
@ -52,7 +77,7 @@ class TimelineInner extends Component {
const { id, completedDate } = completed; const { id, completedDate } = completed;
const { challengeTitle, challengePath } = idToNameMap.get(id); const { challengeTitle, challengePath } = idToNameMap.get(id);
return ( return (
<tr key={id}> <tr className='timeline-row' key={id}>
<td> <td>
<Link to={challengePath}>{challengeTitle}</Link> <Link to={challengePath}>{challengeTitle}</Link>
</td> </td>
@ -81,9 +106,39 @@ class TimelineInner extends Component {
})); }));
} }
firstPage() {
this.setState({
pageNo: 1
});
}
nextPage() {
this.setState(state => ({
pageNo: state.pageNo + 1
}));
}
prevPage() {
this.setState(state => ({
pageNo: state.pageNo - 1
}));
}
lastPage() {
this.setState((_, props) => ({
pageNo: props.totalPages
}));
}
render() { render() {
const { completedMap, idToNameMap, username } = this.props; const {
const { solutionToView: id, solutionOpen } = this.state; completedMap,
idToNameMap,
username,
sortedTimeline,
totalPages = 1
} = this.props;
const { solutionToView: id, solutionOpen, pageNo = 1 } = this.state;
const startIndex = (pageNo - 1) * ITEMS_PER_PAGE;
const endIndex = pageNo * ITEMS_PER_PAGE;
return ( return (
<FullWidthRow> <FullWidthRow>
<h2 className='text-center'>Timeline</h2> <h2 className='text-center'>Timeline</h2>
@ -102,14 +157,9 @@ class TimelineInner extends Component {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{reverse( {sortedTimeline
sortBy(completedMap, ['completedDate']).filter(challenge => { .slice(startIndex, endIndex)
return ( .map(this.renderCompletion)}
challenge.challengeType !== challengeTypes.step &&
idToNameMap.has(challenge.id)
);
})
).map(this.renderCompletion)}
</tbody> </tbody>
</Table> </Table>
)} )}
@ -139,12 +189,22 @@ class TimelineInner extends Component {
</Modal.Footer> </Modal.Footer>
</Modal> </Modal>
)} )}
{totalPages > 1 && (
<TimelinePagination
firstPage={this.firstPage}
lastPage={this.lastPage}
nextPage={this.nextPage}
pageNo={pageNo}
prevPage={this.prevPage}
totalPages={totalPages}
/>
)}
</FullWidthRow> </FullWidthRow>
); );
} }
} }
TimelineInner.propTypes = propTypes; TimelineInner.propTypes = innerPropTypes;
function useIdToNameMap() { function useIdToNameMap() {
const { const {
@ -173,9 +233,32 @@ function useIdToNameMap() {
const Timeline = props => { const Timeline = props => {
const idToNameMap = useIdToNameMap(); const idToNameMap = useIdToNameMap();
return <TimelineInner idToNameMap={idToNameMap} {...props} />; const { completedMap } = props;
// Get the sorted timeline along with total page count.
const { sortedTimeline, totalPages } = useMemo(() => {
const sortedTimeline = reverse(
sortBy(completedMap, ['completedDate']).filter(challenge => {
return (
challenge.challengeType !== challengeTypes.step &&
idToNameMap.has(challenge.id)
);
})
);
const totalPages = Math.ceil(sortedTimeline.length / ITEMS_PER_PAGE);
return { sortedTimeline, totalPages };
}, [completedMap, idToNameMap]);
return (
<TimelineInner
idToNameMap={idToNameMap}
sortedTimeline={sortedTimeline}
totalPages={totalPages}
{...props}
/>
);
}; };
Timeline.propTypes = propTypes;
Timeline.displayName = 'Timeline'; Timeline.displayName = 'Timeline';
export default Timeline; export default Timeline;

View File

@ -0,0 +1,86 @@
import React from 'react';
import PropTypes from 'prop-types';
const TimelinePagination = props => {
const { pageNo, totalPages, firstPage, prevPage, nextPage, lastPage } = props;
return (
<nav aria-label='Timeline Pagination' role='navigation'>
<ul aria-hidden='true' className='timeline-pagination_list'>
{totalPages > 10 && (
<li
className='timeline-pagination_list_item'
style={{
visibility: pageNo === 1 ? 'hidden' : 'unset'
}}
>
<button
aria-label='Go to First page'
disabled={pageNo === 1}
onClick={firstPage}
>
&lt;&lt;
</button>
</li>
)}
<li
className='timeline-pagination_list_item'
style={{
visibility: pageNo === 1 ? 'hidden' : 'unset'
}}
>
<button
aria-label='Go to Previous page'
disabled={pageNo === 1}
onClick={prevPage}
>
&lt;
</button>
</li>
<li className='timeline-pagination_list_item'>
{pageNo} of {totalPages}
</li>
<li
className='timeline-pagination_list_item'
style={{
visibility: pageNo === totalPages ? 'hidden' : 'unset'
}}
>
<button
aria-label='Go to Next page'
disabled={pageNo === totalPages}
onClick={nextPage}
>
&gt;
</button>
</li>
{totalPages > 10 && (
<li
className='timeline-pagination_list_item'
style={{
visibility: pageNo === totalPages ? 'hidden' : 'unset'
}}
>
<button
aria-label='Go to Last page'
disabled={pageNo === totalPages}
onClick={lastPage}
>
&gt;&gt;
</button>
</li>
)}
</ul>
</nav>
);
};
TimelinePagination.propTypes = {
firstPage: PropTypes.func.isRequired,
lastPage: PropTypes.func.isRequired,
nextPage: PropTypes.func.isRequired,
pageNo: PropTypes.number.isRequired,
prevPage: PropTypes.func.isRequired,
totalPages: PropTypes.number.isRequired
};
export default TimelinePagination;

View File

@ -10,3 +10,29 @@
.media { .media {
word-break: break-all; word-break: break-all;
} }
/* Timeline pagination */
.timeline-pagination_list {
list-style: none;
padding: 0;
display: flex;
justify-content: center;
align-items: center;
}
.timeline-pagination_list_item {
margin: 0 5px;
}
.timeline-pagination_list_item > button {
outline: none;
}
/* Timeline table styles */
.timeline-row {
height: 60px;
}
.timeline-row > td {
vertical-align: middle !important;
}