feat(client): isolate progress bar (#49479)

Co-authored-by: Shaun Hamilton <shauhami020@gmail.com>
This commit is contained in:
Ahmad Abdolsaheb
2023-03-27 08:02:55 +00:00
committed by GitHub
co-authored by Shaun Hamilton
parent 0f3083e492
commit 8bd84760b3
16 changed files with 274 additions and 430 deletions
@@ -0,0 +1 @@
export { default } from './progress-bar';
@@ -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<HTMLDivElement>(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 (
<>
<div className='completion-block-name'>{title}</div>
<div
className='progress-bar-wrap'
aria-hidden='true'
ref={progressBarInnerWrap}
>
<div className='progress-bar-background'></div>
<div
className='progress-bar-percent'
data-testid='fcc-progress-bar-percent'
style={{ width: `${shownPercent}%` }}
>
<div
className='progress-bar-foreground'
style={{ width: progressBarInnerWidth }}
></div>
</div>
</div>
<div className='completion-block-meta'>{meta}</div>
</>
);
}
ProgressBarInner.displayName = 'ProgressBarInner';
export default ProgressBarInner;
@@ -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<typeof mapStateToProps>;
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 (
<div className='progress-bar-container'>
<ProgressBarInner
title={blockTitle}
meta={meta}
completedPercent={completedPercent}
/>
</div>
);
}
ProgressBar.displayName = 'ProgressBar';
export default connect(mapStateToProps)(withTranslation()(ProgressBar));
@@ -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;
}
@@ -167,7 +167,6 @@ function ShowClassic({
superBlock,
helpCategory,
forumTopicId,
certification,
usesMultifileEditor,
notes,
videoUrl,
@@ -477,12 +476,7 @@ function ShowClassic({
windowTitle={windowTitle}
/>
</Media>
<CompletionModal
block={block}
blockName={blockName}
certification={certification}
superBlock={superBlock}
/>
<CompletionModal />
<HelpModal challengeTitle={title} challengeBlock={blockName} />
<VideoModal videoUrl={videoUrl} />
<ResetModal />
@@ -518,7 +512,6 @@ export const query = graphql`
helpCategory
videoUrl
superBlock
certification
translationPending
forumTopicId
fields {
@@ -178,15 +178,12 @@ class ShowCodeAlly extends Component<ShowCodeAllyProps> {
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<ShowCodeAllyProps> {
<br />
<Spacer size='medium' />
</Col>
<CompletionModal
block={block}
blockName={blockName}
certification={certification}
superBlock={superBlock}
/>
<CompletionModal />
<HelpModal challengeTitle={title} challengeBlock={blockName} />
</Row>
</Grid>
@@ -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
@@ -1,87 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<CompletionModalBody /> matches snapshot 1`] = `
<div>
<div
class="completion-challenge-details"
>
<svg
aria-label="icons.passed"
class="completion-success-icon"
data-testid="fcc-completion-success-icon"
height="15"
viewBox="0 0 200 200"
width="15"
xmlns="http://www.w3.org/2000/svg"
>
<g
aria-hidden="true"
>
<title>
icons.passed
</title>
<circle
cx="100"
cy="99"
fill="var(--primary-color)"
r="95"
stroke="var(--primary-color)"
stroke-dasharray="null"
/>
<rect
fill="var(--primary-background)"
height="30"
stroke="var(--primary-background)"
stroke-dasharray="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"
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"
>
intro:responsive-web-design.blocks.basic-html-and-html5.title
</div>
<div
aria-label="learn.percent-complete"
class="progress-bar-wrap"
>
<div
aria-hidden="true"
class="progress-bar-background"
>
learn.percent-complete
</div>
<div
aria-hidden="true"
class="progress-bar-percent"
data-testid="fcc-progress-bar-percent"
style="width: 0%;"
>
<div
class="progress-bar-foreground"
>
learn.percent-complete
</div>
</div>
</div>
</div>
</div>
`;
@@ -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('<CompletionModalBody />', () => {
test('matches snapshot', () => {
const { container } = render(<CompletionModalBody {...props} />);
expect(container).toMatchSnapshot();
});
describe('progress-bar', () => {
beforeEach(() => {
jest.useFakeTimers();
});
test('renders with 0% width initially', () => {
render(<CompletionModalBody {...props} />);
expect(screen.getByTestId('fcc-progress-bar-percent')).toHaveStyle({
width: '0%'
});
});
test('has the correct width after animation', () => {
render(<CompletionModalBody {...props} />);
fireEvent.animationEnd(screen.getByTestId('fcc-completion-success-icon'));
jest.runAllTimers();
expect(screen.getByTestId('fcc-progress-bar-percent')).toHaveStyle({
width: `${props.completedPercent}%`
});
});
});
});
@@ -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 (
<>
<div className='completion-challenge-details'>
<GreenPass
className='completion-success-icon'
data-testid='fcc-completion-success-icon'
onAnimationEnd={() => {
setTimeout(() => {
this.animateProgressBar(completedPercent);
}, 50);
}}
/>
</div>
<div className='completion-block-details'>
<div className='completion-block-name'>{blockTitle}</div>
<div
className='progress-bar-wrap'
aria-label={t('learn.percent-complete', {
percent: completedPercent
})}
>
<div className='progress-bar-background' aria-hidden='true'>
{t('learn.percent-complete', {
percent: this.state.shownPercent
})}
</div>
<div
aria-hidden='true'
className='progress-bar-percent'
data-testid='fcc-progress-bar-percent'
style={{ width: `${this.state.shownPercent}%` }}
>
<div className='progress-bar-foreground'>
{t('learn.percent-complete', {
percent: this.state.shownPercent
})}
</div>
</div>
</div>
{isCertificationProject && totalChallengesInBlock > 0 && (
<output>
{t('learn.project-complete', {
completedChallengesInBlock,
totalChallengesInBlock
})}
</output>
)}
</div>
</>
);
}
}
CompletionModalBody.displayName = 'CompletionModalBody';
export default withTranslation()(CompletionModalBody);
@@ -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;
}
}
@@ -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<typeof mapStateToProps>;
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<
<Modal.Title className='completion-message'>{message}</Modal.Title>
</Modal.Header>
<Modal.Body className='completion-modal-body'>
<CompletionModalBody
{...{
block,
completedPercent,
completedChallengesInBlock,
currentChallengeId: id,
superBlock,
totalChallengesInBlock
}}
/>
<div className='completion-challenge-details'>
<GreenPass
className='completion-success-icon'
data-testid='fcc-completion-success-icon'
/>
</div>
<div className='completion-block-details'>
<ProgressBar />
</div>
</Modal.Body>
<Modal.Footer>
{isSignedIn ? null : (
+1 -10
View File
@@ -202,11 +202,9 @@ class ShowOdin extends Component<ShowOdinProps, ShowOdinState> {
data: {
challengeNode: {
challenge: {
fields: { blockName },
title,
description,
superBlock,
certification,
block,
videoId,
videoLocaleIds,
@@ -360,12 +358,7 @@ class ShowOdin extends Component<ShowOdinProps, ShowOdinState> {
</Button>
<Spacer size='large' />
</Col>
<CompletionModal
block={block}
blockName={blockName}
certification={certification}
superBlock={superBlock}
/>
<CompletionModal />
</Row>
</Grid>
</LearnLayout>
@@ -401,10 +394,8 @@ export const query = graphql`
challengeType
helpCategory
superBlock
certification
block
fields {
blockName
slug
}
question {
@@ -199,7 +199,6 @@ class BackEnd extends Component<BackEndProps> {
description,
instructions,
translationPending,
certification,
superBlock,
block
}
@@ -265,12 +264,7 @@ class BackEnd extends Component<BackEndProps> {
<TestSuite tests={tests} />
<Spacer size='medium' />
</Col>
<CompletionModal
block={block}
blockName={blockName}
certification={certification}
superBlock={superBlock}
/>
<CompletionModal />
<HelpModal challengeTitle={title} challengeBlock={blockName} />
</Row>
</Grid>
@@ -297,7 +291,6 @@ export const query = graphql`
instructions
challengeType
helpCategory
certification
superBlock
block
translationPending
@@ -141,7 +141,6 @@ class Project extends Component<ProjectProps> {
description,
instructions,
superBlock,
certification,
block,
translationPending
}
@@ -196,12 +195,7 @@ class Project extends Component<ProjectProps> {
<br />
<Spacer size='medium' />
</Col>
<CompletionModal
block={block}
blockName={blockName}
certification={certification}
superBlock={superBlock}
/>
<CompletionModal />
<HelpModal challengeTitle={title} challengeBlock={blockName} />
</Row>
</Grid>
@@ -229,7 +223,6 @@ export const query = graphql`
challengeType
helpCategory
superBlock
certification
block
translationPending
fields {
+1 -10
View File
@@ -176,11 +176,9 @@ class ShowVideo extends Component<ShowVideoProps, ShowVideoState> {
data: {
challengeNode: {
challenge: {
fields: { blockName },
title,
description,
superBlock,
certification,
block,
translationPending,
videoId,
@@ -298,12 +296,7 @@ class ShowVideo extends Component<ShowVideoProps, ShowVideoState> {
</Button>
<Spacer size='large' />
</Col>
<CompletionModal
block={block}
blockName={blockName}
certification={certification}
superBlock={superBlock}
/>
<CompletionModal />
</Row>
</Grid>
</LearnLayout>
@@ -339,10 +332,8 @@ export const query = graphql`
challengeType
helpCategory
superBlock
certification
block
fields {
blockName
slug
}
question {
@@ -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');
}
);
});