feat(client): completion modal progress bar (#37836)
Co-authored-by: Tom <20648924+moT01@users.noreply.github.com>
This commit is contained in:
5
client/package-lock.json
generated
5
client/package-lock.json
generated
@ -4074,6 +4074,11 @@
|
||||
"resolved": "https://registry.npmjs.org/better-queue-memory/-/better-queue-memory-1.0.4.tgz",
|
||||
"integrity": "sha512-SWg5wFIShYffEmJpI6LgbL8/3Dqhku7xI1oEiy6FroP9DbcZlG0ZDjxvPdP9t7hTGW40IpIcC6zVoGT1oxjOuA=="
|
||||
},
|
||||
"bezier-easing": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/bezier-easing/-/bezier-easing-2.1.0.tgz",
|
||||
"integrity": "sha1-wE3+i5JtbsrKGBPWn/F5t8ICXYY="
|
||||
},
|
||||
"big.js": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/big.js/-/big.js-3.2.0.tgz",
|
||||
|
@ -18,6 +18,7 @@
|
||||
"@reach/router": "^1.2.1",
|
||||
"algoliasearch": "^3.35.1",
|
||||
"axios": "^0.19.0",
|
||||
"bezier-easing": "^2.1.0",
|
||||
"browser-cookies": "^1.2.0",
|
||||
"chai": "^4.2.0",
|
||||
"date-fns": "^1.30.1",
|
||||
|
@ -252,7 +252,11 @@ class ShowClassic extends Component {
|
||||
}
|
||||
|
||||
render() {
|
||||
const { forumTopicId, title } = this.getChallenge();
|
||||
const {
|
||||
fields: { blockName },
|
||||
forumTopicId,
|
||||
title
|
||||
} = this.getChallenge();
|
||||
const {
|
||||
executeChallenge,
|
||||
pageContext: {
|
||||
@ -298,7 +302,7 @@ class ShowClassic extends Component {
|
||||
testOutput={this.renderTestOutput()}
|
||||
/>
|
||||
</Media>
|
||||
<CompletionModal />
|
||||
<CompletionModal blockName={blockName} />
|
||||
<HelpModal />
|
||||
<VideoModal videoUrl={this.getVideoUrl()} />
|
||||
<ResetModal />
|
||||
|
@ -4,10 +4,11 @@ import noop from 'lodash/noop';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { Button, Modal } from '@freecodecamp/react-bootstrap';
|
||||
import { useStaticQuery, graphql } from 'gatsby';
|
||||
|
||||
import ga from '../../../analytics';
|
||||
import Login from '../../../components/Header/components/Login';
|
||||
import GreenPass from '../../../assets/icons/GreenPass';
|
||||
import CompletionModalBody from './CompletionModalBody';
|
||||
|
||||
import { dasherize } from '../../../../../utils/slugs';
|
||||
|
||||
@ -16,6 +17,7 @@ import './completion-modal.css';
|
||||
import {
|
||||
closeModal,
|
||||
submitChallenge,
|
||||
completedChallengesIds,
|
||||
isCompletionModalOpenSelector,
|
||||
successMessageSelector,
|
||||
challengeFilesSelector,
|
||||
@ -27,12 +29,22 @@ import { isSignedInSelector } from '../../../redux';
|
||||
const mapStateToProps = createSelector(
|
||||
challengeFilesSelector,
|
||||
challengeMetaSelector,
|
||||
completedChallengesIds,
|
||||
isCompletionModalOpenSelector,
|
||||
isSignedInSelector,
|
||||
successMessageSelector,
|
||||
(files, { title }, isOpen, isSignedIn, message) => ({
|
||||
(
|
||||
files,
|
||||
{ title, id },
|
||||
completedChallengesIds,
|
||||
isOpen,
|
||||
isSignedIn,
|
||||
message
|
||||
) => ({
|
||||
files,
|
||||
title,
|
||||
id,
|
||||
completedChallengesIds,
|
||||
isOpen,
|
||||
isSignedIn,
|
||||
message
|
||||
@ -59,9 +71,13 @@ const mapDispatchToProps = function(dispatch) {
|
||||
};
|
||||
|
||||
const propTypes = {
|
||||
blockName: PropTypes.string,
|
||||
close: PropTypes.func.isRequired,
|
||||
completedChallengesIds: PropTypes.array,
|
||||
currentBlockIds: PropTypes.array,
|
||||
files: PropTypes.object.isRequired,
|
||||
handleKeypress: PropTypes.func.isRequired,
|
||||
id: PropTypes.string,
|
||||
isOpen: PropTypes.bool,
|
||||
isSignedIn: PropTypes.bool.isRequired,
|
||||
message: PropTypes.string,
|
||||
@ -69,7 +85,27 @@ const propTypes = {
|
||||
title: PropTypes.string
|
||||
};
|
||||
|
||||
export class CompletionModal extends Component {
|
||||
export function getCompletedPercent(
|
||||
completedChallengesIds,
|
||||
currentBlockIds,
|
||||
currentChallengeId
|
||||
) {
|
||||
completedChallengesIds = completedChallengesIds.includes(currentChallengeId)
|
||||
? completedChallengesIds
|
||||
: [...completedChallengesIds, currentChallengeId];
|
||||
|
||||
const completedChallengesInBlock = completedChallengesIds.filter(id => {
|
||||
return currentBlockIds.includes(id);
|
||||
});
|
||||
|
||||
const completedPercent = Math.round(
|
||||
(completedChallengesInBlock.length / currentBlockIds.length) * 100
|
||||
);
|
||||
|
||||
return completedPercent > 100 ? 100 : completedPercent;
|
||||
}
|
||||
|
||||
export class CompletionModalInner extends Component {
|
||||
state = {
|
||||
downloadURL: null
|
||||
};
|
||||
@ -111,7 +147,11 @@ export class CompletionModal extends Component {
|
||||
|
||||
render() {
|
||||
const {
|
||||
blockName = '',
|
||||
close,
|
||||
completedChallengesIds = [],
|
||||
currentBlockIds = [],
|
||||
id = '',
|
||||
isOpen,
|
||||
isSignedIn,
|
||||
submitChallenge,
|
||||
@ -119,6 +159,11 @@ export class CompletionModal extends Component {
|
||||
message,
|
||||
title
|
||||
} = this.props;
|
||||
|
||||
const completedPercent = !isSignedIn
|
||||
? 0
|
||||
: getCompletedPercent(completedChallengesIds, currentBlockIds, id);
|
||||
|
||||
if (isOpen) {
|
||||
ga.modalview('/completion-modal');
|
||||
}
|
||||
@ -137,12 +182,13 @@ export class CompletionModal extends Component {
|
||||
className='challenge-list-header fcc-modal'
|
||||
closeButton={true}
|
||||
>
|
||||
<Modal.Title className='text-center'>{message}</Modal.Title>
|
||||
<Modal.Title className='completion-message'>{message}</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body className='completion-modal-body'>
|
||||
<div className='success-icon-wrapper'>
|
||||
<GreenPass />
|
||||
</div>
|
||||
<CompletionModalBody
|
||||
blockName={blockName}
|
||||
completedPercent={completedPercent}
|
||||
/>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button
|
||||
@ -182,6 +228,37 @@ export class CompletionModal extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
CompletionModalInner.propTypes = propTypes;
|
||||
|
||||
const useCurrentBlockIds = blockName => {
|
||||
const {
|
||||
allChallengeNode: { edges }
|
||||
} = useStaticQuery(graphql`
|
||||
query getCurrentBlockNodes {
|
||||
allChallengeNode(sort: { fields: [superOrder, order, challengeOrder] }) {
|
||||
edges {
|
||||
node {
|
||||
fields {
|
||||
blockName
|
||||
}
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const currentBlockIds = edges
|
||||
.filter(edge => edge.node.fields.blockName === blockName)
|
||||
.map(edge => edge.node.id);
|
||||
return currentBlockIds;
|
||||
};
|
||||
|
||||
const CompletionModal = props => {
|
||||
const currentBlockIds = useCurrentBlockIds(props.blockName || '');
|
||||
return <CompletionModalInner currentBlockIds={currentBlockIds} {...props} />;
|
||||
};
|
||||
|
||||
CompletionModal.displayName = 'CompletionModal';
|
||||
CompletionModal.propTypes = propTypes;
|
||||
|
||||
|
@ -0,0 +1,47 @@
|
||||
/* global expect */
|
||||
|
||||
import '@testing-library/jest-dom/extend-expect';
|
||||
|
||||
import { getCompletedPercent } from './CompletionModal';
|
||||
|
||||
const completedChallengesIds = ['1', '3', '5'],
|
||||
currentBlockIds = ['1', '3', '5', '7'],
|
||||
id = '7',
|
||||
fakeId = '12345',
|
||||
fakeCompletedChallengesIds = ['1', '3', '5', '7', '8'];
|
||||
|
||||
describe('<CompletionModal />', () => {
|
||||
describe('getCompletedPercent', () => {
|
||||
it('returns 0 if no challenges have been completed', () => {
|
||||
expect(getCompletedPercent([], currentBlockIds, fakeId)).toBe(0);
|
||||
});
|
||||
|
||||
it('returns 25 if one out of four challenges are complete', () => {
|
||||
expect(getCompletedPercent([], currentBlockIds, currentBlockIds[1])).toBe(
|
||||
25
|
||||
);
|
||||
});
|
||||
|
||||
it('returns 75 if three out of four challenges are complete', () => {
|
||||
expect(
|
||||
getCompletedPercent(
|
||||
completedChallengesIds,
|
||||
currentBlockIds,
|
||||
completedChallengesIds[0]
|
||||
)
|
||||
).toBe(75);
|
||||
});
|
||||
|
||||
it('returns 100 if all challenges have been completed', () => {
|
||||
expect(
|
||||
getCompletedPercent(completedChallengesIds, currentBlockIds, id)
|
||||
).toBe(100);
|
||||
});
|
||||
|
||||
it('returns 100 if more challenges have been complete than exist', () => {
|
||||
expect(
|
||||
getCompletedPercent(fakeCompletedChallengesIds, currentBlockIds, id)
|
||||
).toBe(100);
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,97 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import BezierEasing from 'bezier-easing';
|
||||
import GreenPass from '../../../assets/icons/GreenPass';
|
||||
|
||||
const propTypes = {
|
||||
blockName: PropTypes.string,
|
||||
completedPercent: PropTypes.number
|
||||
};
|
||||
|
||||
export class CompletionModalBody extends PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
progressInterval: null,
|
||||
shownPercent: 0
|
||||
};
|
||||
|
||||
this.animateProgressBar = this.animateProgressBar.bind(this);
|
||||
}
|
||||
|
||||
animateProgressBar(completedPercent) {
|
||||
const easing = BezierEasing(0.2, 0.5, 0.4, 1);
|
||||
|
||||
if (completedPercent > 100) completedPercent = 100;
|
||||
if (completedPercent < 0) completedPercent = 0;
|
||||
|
||||
const transitionLength = completedPercent * 10 + 750;
|
||||
const intervalLength = 10;
|
||||
const intervalsToFinish = transitionLength / intervalLength;
|
||||
const amountPerInterval = completedPercent / intervalsToFinish;
|
||||
let percent = 0;
|
||||
|
||||
const myInterval = setInterval(() => {
|
||||
percent += amountPerInterval;
|
||||
|
||||
if (percent > completedPercent) percent = completedPercent;
|
||||
|
||||
this.setState({
|
||||
shownPercent: Math.round(
|
||||
completedPercent * easing(percent / completedPercent)
|
||||
)
|
||||
});
|
||||
|
||||
if (percent >= completedPercent) clearInterval(myInterval);
|
||||
}, intervalLength);
|
||||
|
||||
this.setState({
|
||||
progressInterval: myInterval
|
||||
});
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
clearInterval(this.state.progressInterval);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { blockName, completedPercent } = this.props;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='completion-challenge-details'>
|
||||
<GreenPass
|
||||
className='completion-success-icon'
|
||||
onAnimationEnd={() => {
|
||||
setTimeout(() => {
|
||||
this.animateProgressBar(completedPercent);
|
||||
}, 50);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className='completion-block-details'>
|
||||
<div className='completion-block-name'>{blockName}</div>
|
||||
<div className='progress-bar-wrap'>
|
||||
<div className='progress-bar-background'>
|
||||
{this.state.shownPercent}% complete
|
||||
</div>
|
||||
<div
|
||||
className='progress-bar-percent'
|
||||
style={{ width: this.state.shownPercent + '%' }}
|
||||
>
|
||||
<div className='progress-bar-foreground'>
|
||||
{this.state.shownPercent}% complete
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
CompletionModalBody.displayName = 'CompletionModalBody';
|
||||
CompletionModalBody.propTypes = propTypes;
|
||||
|
||||
export default CompletionModalBody;
|
@ -0,0 +1,68 @@
|
||||
/* global jest, expect */
|
||||
|
||||
import '@testing-library/jest-dom/extend-expect';
|
||||
import React from 'react';
|
||||
import { render, fireEvent } from '@testing-library/react';
|
||||
|
||||
import CompletionModalBody from './CompletionModalBody';
|
||||
|
||||
const props = {
|
||||
blockName: 'Basic HTML and HTML5',
|
||||
completedPercent: Math.floor(Math.random() * 101)
|
||||
};
|
||||
|
||||
describe('<CompletionModalBody />', () => {
|
||||
test('matches snapshot', () => {
|
||||
const { container } = render(<CompletionModalBody {...props} />);
|
||||
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
describe('progress-bar', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
test('renders with 0% complete shown initially', () => {
|
||||
const { getAllByText } = render(<CompletionModalBody {...props} />);
|
||||
expect(getAllByText('0% complete').length).toBe(2);
|
||||
});
|
||||
|
||||
test('renders with 0% width initially', () => {
|
||||
const { container } = render(<CompletionModalBody {...props} />);
|
||||
expect(container.querySelector('.progress-bar-percent')).toHaveAttribute(
|
||||
'style',
|
||||
'width: 0%;'
|
||||
);
|
||||
});
|
||||
|
||||
test('shows the correct percent after animation', () => {
|
||||
const { container, getAllByText } = render(
|
||||
<CompletionModalBody {...props} />
|
||||
);
|
||||
const progressBars = getAllByText('0% complete');
|
||||
fireEvent.animationEnd(
|
||||
container.querySelector('.completion-success-icon')
|
||||
);
|
||||
jest.runAllTimers();
|
||||
progressBars.forEach(bar =>
|
||||
expect(bar).toHaveTextContent(`${props.completedPercent}% complete`)
|
||||
);
|
||||
});
|
||||
|
||||
test('has the correct width after animation', () => {
|
||||
const { container } = render(<CompletionModalBody {...props} />);
|
||||
|
||||
fireEvent.animationEnd(
|
||||
container.querySelector('.completion-success-icon')
|
||||
);
|
||||
|
||||
jest.runAllTimers();
|
||||
|
||||
expect(container.querySelector('.progress-bar-percent')).toHaveAttribute(
|
||||
'style',
|
||||
`width: ${props.completedPercent}%;`
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,92 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`<CompletionModalBody /> matches snapshot 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="completion-challenge-details"
|
||||
>
|
||||
<span
|
||||
class="sr-only"
|
||||
>
|
||||
Passed
|
||||
</span>
|
||||
<svg
|
||||
class="completion-success-icon"
|
||||
height="50"
|
||||
viewBox="0 0 200 200"
|
||||
width="50"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g>
|
||||
<title>
|
||||
Passed
|
||||
</title>
|
||||
<circle
|
||||
cx="100"
|
||||
cy="99"
|
||||
fill="var(--primary-color)"
|
||||
r="95"
|
||||
stroke="var(--primary-color)"
|
||||
stroke-dasharray="null"
|
||||
stroke-linecap="null"
|
||||
stroke-linejoin="null"
|
||||
/>
|
||||
<rect
|
||||
fill="var(--primary-background)"
|
||||
height="30"
|
||||
stroke="var(--primary-background)"
|
||||
stroke-dasharray="null"
|
||||
stroke-linecap="null"
|
||||
stroke-linejoin="null"
|
||||
transform="rotate(-45, 120, 106.321)"
|
||||
width="128.85878"
|
||||
x="55.57059"
|
||||
y="91.32089"
|
||||
/>
|
||||
<rect
|
||||
fill="var(--primary-background)"
|
||||
height="30"
|
||||
stroke="var(--primary-background)"
|
||||
stroke-dasharray="null"
|
||||
stroke-linecap="null"
|
||||
stroke-linejoin="null"
|
||||
transform="rotate(45, 66.75, 123.75)"
|
||||
width="80.66548"
|
||||
x="26.41726"
|
||||
y="108.75"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
class="completion-block-details"
|
||||
>
|
||||
<div
|
||||
class="completion-block-name"
|
||||
>
|
||||
Basic HTML and HTML5
|
||||
</div>
|
||||
<div
|
||||
class="progress-bar-wrap"
|
||||
>
|
||||
<div
|
||||
class="progress-bar-background"
|
||||
>
|
||||
0
|
||||
% complete
|
||||
</div>
|
||||
<div
|
||||
class="progress-bar-percent"
|
||||
style="width: 0%;"
|
||||
>
|
||||
<div
|
||||
class="progress-bar-foreground"
|
||||
>
|
||||
0
|
||||
% complete
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
@ -1,17 +1,119 @@
|
||||
.completion-message {
|
||||
text-align: center;
|
||||
font-weight: 700;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.completion-modal-body {
|
||||
height: 45vh;
|
||||
min-height: 400px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
justify-content: space-evenly;
|
||||
}
|
||||
|
||||
.completion-challenge-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.success-icon-wrapper > svg {
|
||||
height: 30vh;
|
||||
width: 30vh;
|
||||
.completion-success-icon {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
transform: scale(1.5);
|
||||
opacity: 0;
|
||||
animation: success-icon-animation 150ms linear 100ms forwards;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 767px) {
|
||||
@keyframes success-icon-animation {
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.completion-block-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.completion-block-name {
|
||||
text-align: center;
|
||||
font-weight: 700;
|
||||
font-size: 1.2rem;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.progress-bar-wrap {
|
||||
width: 400px;
|
||||
height: 50px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.progress-bar-background {
|
||||
width: 400px;
|
||||
height: 50px;
|
||||
color: var(--primary-color);
|
||||
border: 3px solid var(--primary-color);
|
||||
background-color: var(--quaternary-background);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.progress-bar-percent {
|
||||
width: 0;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
background-color: var(--primary-color);
|
||||
transition: width 0ms linear;
|
||||
}
|
||||
|
||||
.progress-bar-foreground {
|
||||
color: var(--primary-background);
|
||||
width: 400px;
|
||||
height: 50px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 991px) {
|
||||
.challenge-success-modal .btn-lg {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.completion-modal-body {
|
||||
min-height: 340px;
|
||||
}
|
||||
|
||||
.progress-bar-wrap,
|
||||
.progress-bar-background,
|
||||
.progress-bar-foreground {
|
||||
width: 260px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.completion-success-icon {
|
||||
width: 160px;
|
||||
height: 160px;
|
||||
}
|
||||
|
||||
.completion-message {
|
||||
font-weight: 600;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.completion-challenge-name,
|
||||
.completion-block-name {
|
||||
font-weight: 400;
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
@ -236,7 +236,7 @@ export class BackEnd extends Component {
|
||||
<TestSuite tests={tests} />
|
||||
<Spacer />
|
||||
</Col>
|
||||
<CompletionModal />
|
||||
<CompletionModal blockName={blockName} />
|
||||
<HelpModal />
|
||||
</Row>
|
||||
</Grid>
|
||||
|
@ -136,7 +136,7 @@ export class Project extends Component {
|
||||
<br />
|
||||
<Spacer />
|
||||
</Col>
|
||||
<CompletionModal />
|
||||
<CompletionModal blockName={blockName} />
|
||||
<HelpModal />
|
||||
</Row>
|
||||
</Grid>
|
||||
|
@ -162,6 +162,8 @@ export const challengeFilesSelector = state => state[ns].challengeFiles;
|
||||
export const challengeMetaSelector = state => state[ns].challengeMeta;
|
||||
export const challengeTestsSelector = state => state[ns].challengeTests;
|
||||
export const consoleOutputSelector = state => state[ns].consoleOut;
|
||||
export const completedChallengesIds = state =>
|
||||
completedChallengesSelector(state).map(node => node.id);
|
||||
export const isChallengeCompletedSelector = state => {
|
||||
const completedChallenges = completedChallengesSelector(state);
|
||||
const { id: currentChallengeId } = challengeMetaSelector(state);
|
||||
|
Reference in New Issue
Block a user