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:
committed by
Randell Dawson
parent
5ea745d61f
commit
174af7fa66
@ -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;
|
||||||
|
@ -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}
|
||||||
|
>
|
||||||
|
<<
|
||||||
|
</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}
|
||||||
|
>
|
||||||
|
<
|
||||||
|
</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}
|
||||||
|
>
|
||||||
|
>
|
||||||
|
</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}
|
||||||
|
>
|
||||||
|
>>
|
||||||
|
</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;
|
@ -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;
|
||||||
|
}
|
||||||
|
Reference in New Issue
Block a user