diff --git a/client/package-lock.json b/client/package-lock.json index 15bfc69d26..c0f90f22c0 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -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", diff --git a/client/package.json b/client/package.json index da0041e8f4..e08641d2e9 100644 --- a/client/package.json +++ b/client/package.json @@ -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", diff --git a/client/src/templates/Challenges/classic/Show.js b/client/src/templates/Challenges/classic/Show.js index 6c8886ff8a..18620dd494 100644 --- a/client/src/templates/Challenges/classic/Show.js +++ b/client/src/templates/Challenges/classic/Show.js @@ -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()} /> - + diff --git a/client/src/templates/Challenges/components/CompletionModal.js b/client/src/templates/Challenges/components/CompletionModal.js index c5794dcf10..91d77ff5f4 100644 --- a/client/src/templates/Challenges/components/CompletionModal.js +++ b/client/src/templates/Challenges/components/CompletionModal.js @@ -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} > - {message} + {message} - - - + { + 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 ; +}; + CompletionModal.displayName = 'CompletionModal'; CompletionModal.propTypes = propTypes; diff --git a/client/src/templates/Challenges/components/CompletionModal.test.js b/client/src/templates/Challenges/components/CompletionModal.test.js new file mode 100644 index 0000000000..e10bc20ff3 --- /dev/null +++ b/client/src/templates/Challenges/components/CompletionModal.test.js @@ -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('', () => { + 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); + }); + }); +}); diff --git a/client/src/templates/Challenges/components/CompletionModalBody.js b/client/src/templates/Challenges/components/CompletionModalBody.js new file mode 100644 index 0000000000..652eb5c6af --- /dev/null +++ b/client/src/templates/Challenges/components/CompletionModalBody.js @@ -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 ( + <> + + { + setTimeout(() => { + this.animateProgressBar(completedPercent); + }, 50); + }} + /> + + + {blockName} + + + {this.state.shownPercent}% complete + + + + {this.state.shownPercent}% complete + + + + + > + ); + } +} + +CompletionModalBody.displayName = 'CompletionModalBody'; +CompletionModalBody.propTypes = propTypes; + +export default CompletionModalBody; diff --git a/client/src/templates/Challenges/components/CompletionModalBody.test.js b/client/src/templates/Challenges/components/CompletionModalBody.test.js new file mode 100644 index 0000000000..1159ab362d --- /dev/null +++ b/client/src/templates/Challenges/components/CompletionModalBody.test.js @@ -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('', () => { + test('matches snapshot', () => { + const { container } = render(); + + expect(container).toMatchSnapshot(); + }); + + describe('progress-bar', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + test('renders with 0% complete shown initially', () => { + const { getAllByText } = render(); + expect(getAllByText('0% complete').length).toBe(2); + }); + + test('renders with 0% width initially', () => { + const { container } = render(); + expect(container.querySelector('.progress-bar-percent')).toHaveAttribute( + 'style', + 'width: 0%;' + ); + }); + + test('shows the correct percent after animation', () => { + const { container, getAllByText } = render( + + ); + 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(); + + fireEvent.animationEnd( + container.querySelector('.completion-success-icon') + ); + + jest.runAllTimers(); + + expect(container.querySelector('.progress-bar-percent')).toHaveAttribute( + 'style', + `width: ${props.completedPercent}%;` + ); + }); + }); +}); diff --git a/client/src/templates/Challenges/components/__snapshots__/CompletionModalBody.test.js.snap b/client/src/templates/Challenges/components/__snapshots__/CompletionModalBody.test.js.snap new file mode 100644 index 0000000000..86ef581518 --- /dev/null +++ b/client/src/templates/Challenges/components/__snapshots__/CompletionModalBody.test.js.snap @@ -0,0 +1,92 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` matches snapshot 1`] = ` + + + + Passed + + + + + Passed + + + + + + + + + + Basic HTML and HTML5 + + + + 0 + % complete + + + + 0 + % complete + + + + + +`; diff --git a/client/src/templates/Challenges/components/completion-modal.css b/client/src/templates/Challenges/components/completion-modal.css index 6f2a6563d6..cfd8e325f3 100644 --- a/client/src/templates/Challenges/components/completion-modal.css +++ b/client/src/templates/Challenges/components/completion-modal.css @@ -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; + } } diff --git a/client/src/templates/Challenges/projects/backend/Show.js b/client/src/templates/Challenges/projects/backend/Show.js index 8ca5625b80..d62b93715f 100644 --- a/client/src/templates/Challenges/projects/backend/Show.js +++ b/client/src/templates/Challenges/projects/backend/Show.js @@ -236,7 +236,7 @@ export class BackEnd extends Component { - + diff --git a/client/src/templates/Challenges/projects/frontend/Show.js b/client/src/templates/Challenges/projects/frontend/Show.js index 3195b5ccbf..f13bad2d6a 100644 --- a/client/src/templates/Challenges/projects/frontend/Show.js +++ b/client/src/templates/Challenges/projects/frontend/Show.js @@ -136,7 +136,7 @@ export class Project extends Component { - + diff --git a/client/src/templates/Challenges/redux/index.js b/client/src/templates/Challenges/redux/index.js index dc075108f7..7c12537257 100644 --- a/client/src/templates/Challenges/redux/index.js +++ b/client/src/templates/Challenges/redux/index.js @@ -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);