feat(client): completion modal progress bar (#37836)

Co-authored-by: Tom <20648924+moT01@users.noreply.github.com>
This commit is contained in:
mrugesh
2019-11-26 22:14:44 +05:30
committed by GitHub
parent 1d73575a59
commit 1b61bceee7
12 changed files with 512 additions and 17 deletions

View File

@ -4074,6 +4074,11 @@
"resolved": "https://registry.npmjs.org/better-queue-memory/-/better-queue-memory-1.0.4.tgz", "resolved": "https://registry.npmjs.org/better-queue-memory/-/better-queue-memory-1.0.4.tgz",
"integrity": "sha512-SWg5wFIShYffEmJpI6LgbL8/3Dqhku7xI1oEiy6FroP9DbcZlG0ZDjxvPdP9t7hTGW40IpIcC6zVoGT1oxjOuA==" "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": { "big.js": {
"version": "3.2.0", "version": "3.2.0",
"resolved": "https://registry.npmjs.org/big.js/-/big.js-3.2.0.tgz", "resolved": "https://registry.npmjs.org/big.js/-/big.js-3.2.0.tgz",

View File

@ -18,6 +18,7 @@
"@reach/router": "^1.2.1", "@reach/router": "^1.2.1",
"algoliasearch": "^3.35.1", "algoliasearch": "^3.35.1",
"axios": "^0.19.0", "axios": "^0.19.0",
"bezier-easing": "^2.1.0",
"browser-cookies": "^1.2.0", "browser-cookies": "^1.2.0",
"chai": "^4.2.0", "chai": "^4.2.0",
"date-fns": "^1.30.1", "date-fns": "^1.30.1",

View File

@ -252,7 +252,11 @@ class ShowClassic extends Component {
} }
render() { render() {
const { forumTopicId, title } = this.getChallenge(); const {
fields: { blockName },
forumTopicId,
title
} = this.getChallenge();
const { const {
executeChallenge, executeChallenge,
pageContext: { pageContext: {
@ -298,7 +302,7 @@ class ShowClassic extends Component {
testOutput={this.renderTestOutput()} testOutput={this.renderTestOutput()}
/> />
</Media> </Media>
<CompletionModal /> <CompletionModal blockName={blockName} />
<HelpModal /> <HelpModal />
<VideoModal videoUrl={this.getVideoUrl()} /> <VideoModal videoUrl={this.getVideoUrl()} />
<ResetModal /> <ResetModal />

View File

@ -4,10 +4,11 @@ import noop from 'lodash/noop';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import { Button, Modal } from '@freecodecamp/react-bootstrap'; import { Button, Modal } from '@freecodecamp/react-bootstrap';
import { useStaticQuery, graphql } from 'gatsby';
import ga from '../../../analytics'; import ga from '../../../analytics';
import Login from '../../../components/Header/components/Login'; import Login from '../../../components/Header/components/Login';
import GreenPass from '../../../assets/icons/GreenPass'; import CompletionModalBody from './CompletionModalBody';
import { dasherize } from '../../../../../utils/slugs'; import { dasherize } from '../../../../../utils/slugs';
@ -16,6 +17,7 @@ import './completion-modal.css';
import { import {
closeModal, closeModal,
submitChallenge, submitChallenge,
completedChallengesIds,
isCompletionModalOpenSelector, isCompletionModalOpenSelector,
successMessageSelector, successMessageSelector,
challengeFilesSelector, challengeFilesSelector,
@ -27,12 +29,22 @@ import { isSignedInSelector } from '../../../redux';
const mapStateToProps = createSelector( const mapStateToProps = createSelector(
challengeFilesSelector, challengeFilesSelector,
challengeMetaSelector, challengeMetaSelector,
completedChallengesIds,
isCompletionModalOpenSelector, isCompletionModalOpenSelector,
isSignedInSelector, isSignedInSelector,
successMessageSelector, successMessageSelector,
(files, { title }, isOpen, isSignedIn, message) => ({ (
files,
{ title, id },
completedChallengesIds,
isOpen,
isSignedIn,
message
) => ({
files, files,
title, title,
id,
completedChallengesIds,
isOpen, isOpen,
isSignedIn, isSignedIn,
message message
@ -59,9 +71,13 @@ const mapDispatchToProps = function(dispatch) {
}; };
const propTypes = { const propTypes = {
blockName: PropTypes.string,
close: PropTypes.func.isRequired, close: PropTypes.func.isRequired,
completedChallengesIds: PropTypes.array,
currentBlockIds: PropTypes.array,
files: PropTypes.object.isRequired, files: PropTypes.object.isRequired,
handleKeypress: PropTypes.func.isRequired, handleKeypress: PropTypes.func.isRequired,
id: PropTypes.string,
isOpen: PropTypes.bool, isOpen: PropTypes.bool,
isSignedIn: PropTypes.bool.isRequired, isSignedIn: PropTypes.bool.isRequired,
message: PropTypes.string, message: PropTypes.string,
@ -69,7 +85,27 @@ const propTypes = {
title: PropTypes.string 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 = { state = {
downloadURL: null downloadURL: null
}; };
@ -111,7 +147,11 @@ export class CompletionModal extends Component {
render() { render() {
const { const {
blockName = '',
close, close,
completedChallengesIds = [],
currentBlockIds = [],
id = '',
isOpen, isOpen,
isSignedIn, isSignedIn,
submitChallenge, submitChallenge,
@ -119,6 +159,11 @@ export class CompletionModal extends Component {
message, message,
title title
} = this.props; } = this.props;
const completedPercent = !isSignedIn
? 0
: getCompletedPercent(completedChallengesIds, currentBlockIds, id);
if (isOpen) { if (isOpen) {
ga.modalview('/completion-modal'); ga.modalview('/completion-modal');
} }
@ -137,12 +182,13 @@ export class CompletionModal extends Component {
className='challenge-list-header fcc-modal' className='challenge-list-header fcc-modal'
closeButton={true} closeButton={true}
> >
<Modal.Title className='text-center'>{message}</Modal.Title> <Modal.Title className='completion-message'>{message}</Modal.Title>
</Modal.Header> </Modal.Header>
<Modal.Body className='completion-modal-body'> <Modal.Body className='completion-modal-body'>
<div className='success-icon-wrapper'> <CompletionModalBody
<GreenPass /> blockName={blockName}
</div> completedPercent={completedPercent}
/>
</Modal.Body> </Modal.Body>
<Modal.Footer> <Modal.Footer>
<Button <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.displayName = 'CompletionModal';
CompletionModal.propTypes = propTypes; CompletionModal.propTypes = propTypes;

View File

@ -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);
});
});
});

View File

@ -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;

View File

@ -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}%;`
);
});
});
});

View File

@ -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>
`;

View File

@ -1,17 +1,119 @@
.completion-message {
text-align: center;
font-weight: 700;
font-size: 1.5rem;
}
.completion-modal-body { .completion-modal-body {
height: 45vh; min-height: 400px;
display: flex; display: flex;
justify-content: center; flex-direction: column;
justify-content: space-evenly;
}
.completion-challenge-details {
display: flex;
flex-direction: column;
align-items: center; align-items: center;
justify-content: center;
margin-bottom: 15px;
} }
.success-icon-wrapper > svg { .completion-success-icon {
height: 30vh; width: 200px;
width: 30vh; 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 { .challenge-success-modal .btn-lg {
font-size: 16px; 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;
}
} }

View File

@ -236,7 +236,7 @@ export class BackEnd extends Component {
<TestSuite tests={tests} /> <TestSuite tests={tests} />
<Spacer /> <Spacer />
</Col> </Col>
<CompletionModal /> <CompletionModal blockName={blockName} />
<HelpModal /> <HelpModal />
</Row> </Row>
</Grid> </Grid>

View File

@ -136,7 +136,7 @@ export class Project extends Component {
<br /> <br />
<Spacer /> <Spacer />
</Col> </Col>
<CompletionModal /> <CompletionModal blockName={blockName} />
<HelpModal /> <HelpModal />
</Row> </Row>
</Grid> </Grid>

View File

@ -162,6 +162,8 @@ export const challengeFilesSelector = state => state[ns].challengeFiles;
export const challengeMetaSelector = state => state[ns].challengeMeta; export const challengeMetaSelector = state => state[ns].challengeMeta;
export const challengeTestsSelector = state => state[ns].challengeTests; export const challengeTestsSelector = state => state[ns].challengeTests;
export const consoleOutputSelector = state => state[ns].consoleOut; export const consoleOutputSelector = state => state[ns].consoleOut;
export const completedChallengesIds = state =>
completedChallengesSelector(state).map(node => node.id);
export const isChallengeCompletedSelector = state => { export const isChallengeCompletedSelector = state => {
const completedChallenges = completedChallengesSelector(state); const completedChallenges = completedChallengesSelector(state);
const { id: currentChallengeId } = challengeMetaSelector(state); const { id: currentChallengeId } = challengeMetaSelector(state);