diff --git a/client/src/components/ProgressBar/index.tsx b/client/src/components/ProgressBar/index.tsx new file mode 100644 index 00000000000..d6f960cec7d --- /dev/null +++ b/client/src/components/ProgressBar/index.tsx @@ -0,0 +1 @@ +export { default } from './progress-bar'; diff --git a/client/src/components/ProgressBar/progress-bar-inner.tsx b/client/src/components/ProgressBar/progress-bar-inner.tsx new file mode 100644 index 00000000000..ea4b0ebf6d6 --- /dev/null +++ b/client/src/components/ProgressBar/progress-bar-inner.tsx @@ -0,0 +1,89 @@ +import React, { useState, useEffect, useRef } from 'react'; +import BezierEasing from 'bezier-easing'; + +interface ProgressBarInnerProps { + completedPercent: number; + title: string; + meta: string; +} + +const easing = BezierEasing(0.2, 0.5, 0.4, 1); +const intervalLength = 10; +let percent = 0; +let applyAnimation = true; + +function ProgressBarInner({ + completedPercent, + title, + meta +}: ProgressBarInnerProps): JSX.Element { + const [shownPercent, setShownPercent] = useState(0); + const [progressInterval, setProgressInterval] = useState(0); + const [progressBarInnerWidth, setProgressBarInnerWidth] = useState(0); + const progressBarInnerWrap = useRef(null); + + const animateProgressBarInner = (completedPercent: number) => { + if (completedPercent > 100) completedPercent = 100; + if (completedPercent < 0) completedPercent = 0; + + const transitionLength = completedPercent * 10 + 750; + const intervalsToFinish = transitionLength / intervalLength; + const amountPerInterval = completedPercent / intervalsToFinish; + + const myInterval = window.setInterval(() => { + percent += amountPerInterval; + + if (percent > completedPercent) percent = completedPercent; + + setShownPercent( + Math.round(completedPercent * easing(percent / completedPercent)) + ); + if (percent >= completedPercent) clearInterval(myInterval); + }, intervalLength); + + setProgressInterval(myInterval); + }; + useEffect(() => { + if (applyAnimation) animateProgressBarInner(completedPercent); + return () => { + if (progressInterval !== null) { + clearInterval(progressInterval); + applyAnimation = false; + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [completedPercent]); + + useEffect(() => { + if (progressBarInnerWrap.current) + setProgressBarInnerWidth(progressBarInnerWrap.current.offsetWidth); + }, [progressBarInnerWrap]); + + return ( + <> +
{title}
+ +
{meta}
+ + ); +} + +ProgressBarInner.displayName = 'ProgressBarInner'; + +export default ProgressBarInner; diff --git a/client/src/components/ProgressBar/progress-bar.tsx b/client/src/components/ProgressBar/progress-bar.tsx new file mode 100644 index 00000000000..744f04ecb94 --- /dev/null +++ b/client/src/components/ProgressBar/progress-bar.tsx @@ -0,0 +1,86 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { TFunction, withTranslation } from 'react-i18next'; +import { + challengeMetaSelector, + currentBlockIdsSelector, + completedChallengesInBlockSelector, + completedPercentageSelector +} from '../../templates/Challenges/redux/selectors'; +import { certMap } from '../../resources/cert-and-project-map'; +import ProgressBarInner from './progress-bar-inner'; + +const mapStateToProps = createSelector( + currentBlockIdsSelector, + challengeMetaSelector, + completedChallengesInBlockSelector, + completedPercentageSelector, + ( + currentBlockIds: string[], + { + id, + block, + superBlock + }: { + id: string; + block: string; + superBlock: string; + }, + completedChallengesInBlock: number, + completedPercent: number + ) => ({ + currentBlockIds, + id, + block, + superBlock, + completedChallengesInBlock, + completedPercent + }) +); + +type StateProps = ReturnType; + +interface ProgressBarProps extends StateProps { + t: TFunction; +} +function ProgressBar({ + currentBlockIds, + block, + id, + superBlock, + completedChallengesInBlock, + completedPercent, + t +}: ProgressBarProps): JSX.Element { + const blockTitle = t(`intro:${superBlock}.blocks.${block}.title`); + const isCertificationProject = certMap.some(cert => { + if ('projects' in cert) { + return cert.projects.some((project: { id: string }) => project.id === id); + } + }); + + const totalChallengesInBlock = currentBlockIds?.length ?? 0; + const meta = + isCertificationProject && totalChallengesInBlock > 0 + ? t('learn.project-complete', { + completedChallengesInBlock, + totalChallengesInBlock + }) + : t('learn.percent-complete', { + percent: completedPercent + }); + return ( +
+ +
+ ); +} + +ProgressBar.displayName = 'ProgressBar'; + +export default connect(mapStateToProps)(withTranslation()(ProgressBar)); diff --git a/client/src/templates/Challenges/classic/editor.css b/client/src/templates/Challenges/classic/editor.css index 6f72fa43335..07efb8e18d8 100644 --- a/client/src/templates/Challenges/classic/editor.css +++ b/client/src/templates/Challenges/classic/editor.css @@ -142,10 +142,10 @@ textarea.inputarea { .test-feedback .test-status, .test-feedback .hint-status { - margin-top: 1rem; display: flex; gap: 0.5rem; flex-direction: row; + margin-top: 1rem; } .test-status-description, @@ -186,3 +186,20 @@ textarea.inputarea { .lower-jaw-icon-bar > button:last-child { margin-inline-end: 0; } + +.progress-bar-container { + text-align: center; +} + +.editor-lower-jaw .progress-bar-wrap-container { + width: 100% !important; + margin: 32px 0px; +} + +.editor-lower-jaw .progress-bar-background-container { + width: 100% !important; +} + +.hint-status { + margin-bottom: 18px; +} diff --git a/client/src/templates/Challenges/classic/show.tsx b/client/src/templates/Challenges/classic/show.tsx index 64cf4bc9853..3b8f876bd42 100644 --- a/client/src/templates/Challenges/classic/show.tsx +++ b/client/src/templates/Challenges/classic/show.tsx @@ -167,7 +167,6 @@ function ShowClassic({ superBlock, helpCategory, forumTopicId, - certification, usesMultifileEditor, notes, videoUrl, @@ -477,12 +476,7 @@ function ShowClassic({ windowTitle={windowTitle} /> - + @@ -518,7 +512,6 @@ export const query = graphql` helpCategory videoUrl superBlock - certification translationPending forumTopicId fields { diff --git a/client/src/templates/Challenges/codeally/show.tsx b/client/src/templates/Challenges/codeally/show.tsx index bc5587485d6..841a93c0df8 100644 --- a/client/src/templates/Challenges/codeally/show.tsx +++ b/client/src/templates/Challenges/codeally/show.tsx @@ -178,15 +178,12 @@ class ShowCodeAlly extends Component { data: { challengeNode: { challenge: { - block, - certification, challengeType, description, fields: { blockName }, id: challengeId, instructions, notes, - superBlock, title, translationPending, url @@ -351,12 +348,7 @@ class ShowCodeAlly extends Component {
- + @@ -378,8 +370,6 @@ export const query = graphql` query CodeAllyChallenge($slug: String!) { challengeNode(challenge: { fields: { slug: { eq: $slug } } }) { challenge { - block - certification challengeType description fields { @@ -389,7 +379,6 @@ export const query = graphql` id instructions notes - superBlock title translationPending url diff --git a/client/src/templates/Challenges/components/__snapshots__/completion-modal-body.test.tsx.snap b/client/src/templates/Challenges/components/__snapshots__/completion-modal-body.test.tsx.snap deleted file mode 100644 index 923a2048541..00000000000 --- a/client/src/templates/Challenges/components/__snapshots__/completion-modal-body.test.tsx.snap +++ /dev/null @@ -1,87 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[` matches snapshot 1`] = ` -
-
- - - -
-
-
- intro:responsive-web-design.blocks.basic-html-and-html5.title -
-
- - -
-
-
-`; diff --git a/client/src/templates/Challenges/components/completion-modal-body.test.tsx b/client/src/templates/Challenges/components/completion-modal-body.test.tsx deleted file mode 100644 index c0aefbb5d89..00000000000 --- a/client/src/templates/Challenges/components/completion-modal-body.test.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { render, fireEvent, screen } from '@testing-library/react'; -import React from 'react'; - -import { SuperBlocks } from '../../../../../config/certification-settings'; - -import CompletionModalBody from './completion-modal-body'; - -const props = { - block: 'basic-html-and-html5', - completedPercent: Math.floor(Math.random() * 101), - completedChallengesInBlock: 2, - totalChallengesInBlock: 5, - currentChallengeId: '', - superBlock: SuperBlocks.RespWebDesign -}; - -describe('', () => { - test('matches snapshot', () => { - const { container } = render(); - - expect(container).toMatchSnapshot(); - }); - - describe('progress-bar', () => { - beforeEach(() => { - jest.useFakeTimers(); - }); - - test('renders with 0% width initially', () => { - render(); - expect(screen.getByTestId('fcc-progress-bar-percent')).toHaveStyle({ - width: '0%' - }); - }); - - test('has the correct width after animation', () => { - render(); - - fireEvent.animationEnd(screen.getByTestId('fcc-completion-success-icon')); - - jest.runAllTimers(); - - expect(screen.getByTestId('fcc-progress-bar-percent')).toHaveStyle({ - width: `${props.completedPercent}%` - }); - }); - }); -}); diff --git a/client/src/templates/Challenges/components/completion-modal-body.tsx b/client/src/templates/Challenges/components/completion-modal-body.tsx deleted file mode 100644 index cc0cf56da53..00000000000 --- a/client/src/templates/Challenges/components/completion-modal-body.tsx +++ /dev/null @@ -1,150 +0,0 @@ -import BezierEasing from 'bezier-easing'; -import React, { PureComponent } from 'react'; -import { TFunction, withTranslation } from 'react-i18next'; -import GreenPass from '../../../assets/icons/green-pass'; -import { certMap } from '../../../resources/cert-and-project-map'; - -interface CompletionModalBodyProps { - block: string; - completedChallengesInBlock: number; - completedPercent: number; - currentChallengeId: string; - superBlock: string; - t: TFunction; - totalChallengesInBlock: number; -} - -interface CompletionModalBodyState { - // This type was driving me nuts - seems like `NodeJS.Timeout | null;` should work - // eslint-disable-next-line @typescript-eslint/no-explicit-any - progressInterval: number | null; - shownPercent: number; -} - -export class CompletionModalBody extends PureComponent< - CompletionModalBodyProps, - CompletionModalBodyState -> { - static displayName: string; - constructor(props: CompletionModalBodyProps) { - super(props); - - this.state = { - progressInterval: null, - shownPercent: 0 - }; - - this.animateProgressBar = this.animateProgressBar.bind(this); - } - - animateProgressBar(completedPercent: number): void { - 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 = window.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(): void { - if (this.state.progressInterval !== null) - clearInterval(this.state.progressInterval); - } - - render(): JSX.Element { - const { - block, - completedPercent, - totalChallengesInBlock, - completedChallengesInBlock, - currentChallengeId, - superBlock, - t - } = this.props; - const blockTitle = t(`intro:${superBlock}.blocks.${block}.title`); - const isCertificationProject = certMap.some(cert => { - // @ts-expect-error If `projects` does not exist, no consequences - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call - return cert.projects?.some( - (project: { id: string }) => project.id === currentChallengeId - ); - }); - - return ( - <> -
- { - setTimeout(() => { - this.animateProgressBar(completedPercent); - }, 50); - }} - /> -
-
-
{blockTitle}
-
- - -
- {isCertificationProject && totalChallengesInBlock > 0 && ( - - {t('learn.project-complete', { - completedChallengesInBlock, - totalChallengesInBlock - })} - - )} -
- - ); - } -} - -CompletionModalBody.displayName = 'CompletionModalBody'; - -export default withTranslation()(CompletionModalBody); diff --git a/client/src/templates/Challenges/components/completion-modal.css b/client/src/templates/Challenges/components/completion-modal.css index b62125c860b..69f4abba8f2 100644 --- a/client/src/templates/Challenges/components/completion-modal.css +++ b/client/src/templates/Challenges/components/completion-modal.css @@ -51,27 +51,42 @@ display: flex; flex-direction: column; align-items: center; + align-self: center; justify-content: space-between; + width: 100%; + gap: 0.5rem; + max-width: 500px; } .completion-block-name { - text-align: center; font-weight: 700; + font-size: 1rem; + margin-bottom: 4px; +} + +.completion-block-meta { + color: var(--quaternary-color); + font-size: 0.8rem; + margin-top: 5px; +} + +.completion-block-details .completion-block-name { font-size: 1.2rem; - margin-bottom: 15px; +} + +.completion-block-details .completion-block-meta { + font-size: 1rem; } .progress-bar-wrap { - width: 400px; - height: 50px; + width: 100%; position: relative; } .progress-bar-background { - width: 400px; - height: 50px; + width: 100%; + height: 10px; color: var(--primary-color); - border: 3px solid var(--primary-color); background-color: var(--quaternary-background); display: flex; align-items: center; @@ -92,7 +107,7 @@ .progress-bar-foreground { color: var(--primary-background); width: 400px; - height: 50px; + height: 10px; display: flex; align-items: center; justify-content: center; @@ -113,8 +128,7 @@ .progress-bar-wrap, .progress-bar-background, .progress-bar-foreground { - width: 260px; - height: 40px; + height: 10px; } .completion-success-icon { @@ -129,7 +143,6 @@ .completion-challenge-name, .completion-block-name { - font-weight: 400; font-size: 1rem; } } diff --git a/client/src/templates/Challenges/components/completion-modal.tsx b/client/src/templates/Challenges/components/completion-modal.tsx index 0f2280fd395..5c54cf20241 100644 --- a/client/src/templates/Challenges/components/completion-modal.tsx +++ b/client/src/templates/Challenges/components/completion-modal.tsx @@ -5,7 +5,6 @@ import { noop } from 'lodash-es'; import React, { Component } from 'react'; import { TFunction, withTranslation } from 'react-i18next'; import { connect } from 'react-redux'; -import { Dispatch } from 'redux'; import { createSelector } from 'reselect'; import { dasherize } from '../../../../../utils/slugs'; @@ -22,12 +21,10 @@ import { isCompletionModalOpenSelector, successMessageSelector, challengeFilesSelector, - challengeMetaSelector, - completedPercentageSelector, - completedChallengesInBlockSelector, - currentBlockIdsSelector + challengeMetaSelector } from '../redux/selectors'; -import CompletionModalBody from './completion-modal-body'; +import ProgressBar from '../../../components/ProgressBar'; +import GreenPass from '../../../assets/icons/green-pass'; import './completion-modal.css'; @@ -39,72 +36,39 @@ const mapStateToProps = createSelector( isSignedInSelector, allChallengesInfoSelector, successMessageSelector, - completedPercentageSelector, - completedChallengesInBlockSelector, - currentBlockIdsSelector, + ( challengeFiles: ChallengeFiles, - { - title, - id, - challengeType - }: { title: string; id: string; challengeType: number }, + { title }: { title: string; id: string }, completedChallengesIds: string[], isOpen: boolean, isSignedIn: boolean, allChallengesInfo: AllChallengesInfo, - message: string, - completedPercent: number, - completedChallengesInBlock: number, - currentBlockIds: string[] + message: string ) => ({ challengeFiles, title, - id, - challengeType, completedChallengesIds, isOpen, isSignedIn, allChallengesInfo, - message, - completedPercent, - completedChallengesInBlock, - currentBlockIds + message }) ); -const mapDispatchToProps = function (dispatch: Dispatch) { - const dispatchers = { - close: () => dispatch(closeModal('completion')), - submitChallenge: () => { - dispatch(submitChallenge()); - }, - executeGA - }; - return () => dispatchers; +const mapDispatchToProps = { + close: () => closeModal('completion'), + submitChallenge, + executeGA }; -interface CompletionModalsProps { - block: string; - blockName: string; - certification: string; - challengeType: number; +type StateProps = ReturnType; + +interface CompletionModalsProps extends StateProps { close: () => void; - completedChallengesIds: string[]; executeGA: () => void; - challengeFiles: ChallengeFiles; - id: string; - isOpen: boolean; - isSignedIn: boolean; - allChallengesInfo: AllChallengesInfo; - message: string; - completedPercent: number; - completedChallengesInBlock: number; - currentBlockIds: string[]; submitChallenge: () => void; - superBlock: string; t: TFunction; - title: string; } interface CompletionModalState { @@ -179,23 +143,8 @@ class CompletionModal extends Component< } render(): JSX.Element { - const { - block, - close, - id, - isOpen, - isSignedIn, - message, - superBlock = '', - t, - title, - completedPercent, - completedChallengesInBlock, - currentBlockIds, - submitChallenge - } = this.props; - - const totalChallengesInBlock = currentBlockIds.length; + const { close, isOpen, isSignedIn, message, t, title, submitChallenge } = + this.props; if (isOpen) { executeGA({ event: 'pageview', pagePath: '/completion-modal' }); @@ -222,16 +171,15 @@ class CompletionModal extends Component< {message} - +
+ +
+
+ +
{isSignedIn ? null : ( diff --git a/client/src/templates/Challenges/odin/show.tsx b/client/src/templates/Challenges/odin/show.tsx index ff936e25596..4d6a24cbe59 100644 --- a/client/src/templates/Challenges/odin/show.tsx +++ b/client/src/templates/Challenges/odin/show.tsx @@ -202,11 +202,9 @@ class ShowOdin extends Component { data: { challengeNode: { challenge: { - fields: { blockName }, title, description, superBlock, - certification, block, videoId, videoLocaleIds, @@ -360,12 +358,7 @@ class ShowOdin extends Component { - + @@ -401,10 +394,8 @@ export const query = graphql` challengeType helpCategory superBlock - certification block fields { - blockName slug } question { diff --git a/client/src/templates/Challenges/projects/backend/show.tsx b/client/src/templates/Challenges/projects/backend/show.tsx index 94c0f3d6e61..988cf596ff3 100644 --- a/client/src/templates/Challenges/projects/backend/show.tsx +++ b/client/src/templates/Challenges/projects/backend/show.tsx @@ -199,7 +199,6 @@ class BackEnd extends Component { description, instructions, translationPending, - certification, superBlock, block } @@ -265,12 +264,7 @@ class BackEnd extends Component { - + @@ -297,7 +291,6 @@ export const query = graphql` instructions challengeType helpCategory - certification superBlock block translationPending diff --git a/client/src/templates/Challenges/projects/frontend/show.tsx b/client/src/templates/Challenges/projects/frontend/show.tsx index b8f0e8eb73f..84fc7540783 100644 --- a/client/src/templates/Challenges/projects/frontend/show.tsx +++ b/client/src/templates/Challenges/projects/frontend/show.tsx @@ -141,7 +141,6 @@ class Project extends Component { description, instructions, superBlock, - certification, block, translationPending } @@ -196,12 +195,7 @@ class Project extends Component {
- + @@ -229,7 +223,6 @@ export const query = graphql` challengeType helpCategory superBlock - certification block translationPending fields { diff --git a/client/src/templates/Challenges/video/show.tsx b/client/src/templates/Challenges/video/show.tsx index 3e5fbea88e2..5cc4b1b022d 100644 --- a/client/src/templates/Challenges/video/show.tsx +++ b/client/src/templates/Challenges/video/show.tsx @@ -176,11 +176,9 @@ class ShowVideo extends Component { data: { challengeNode: { challenge: { - fields: { blockName }, title, description, superBlock, - certification, block, translationPending, videoId, @@ -298,12 +296,7 @@ class ShowVideo extends Component { - + @@ -339,10 +332,8 @@ export const query = graphql` challengeType helpCategory superBlock - certification block fields { - blockName slug } question { diff --git a/cypress/e2e/default/learn/challenges/progress-bar.ts b/cypress/e2e/default/learn/challenges/progress-bar.ts new file mode 100644 index 00000000000..aac3b22f7d5 --- /dev/null +++ b/cypress/e2e/default/learn/challenges/progress-bar.ts @@ -0,0 +1,25 @@ +describe('progress bar', () => { + beforeEach(() => { + cy.exec('pnpm run seed'); + cy.login(); + }); + + it( + 'Should show the progress bar showing the completed percent', + { browser: 'electron' }, + () => { + cy.visit( + '/learn/javascript-algorithms-and-data-structures/basic-javascript/declare-javascript-variables' + ); + cy.get(`${'.react-monaco-editor-container'} textarea`, { timeout: 16000 }) + .click() + .focused() + .type('{ctrl}a') + .clear() + .type('var myName;'); + cy.contains('Run the Tests (Ctrl + Enter)').click({ force: true }); + cy.contains('Submit and go to next challenge'); + cy.get('.progress-bar-container').contains('1% complete'); + } + ); +});