feat(client): progress summary on the main courses page (#49010)

Co-authored-by: Muhammed Mustafa <muhammed@freecodecamp.org>
Co-authored-by: Bruce Blaser <bbsmooth@gmail.com>
Co-authored-by: Ahmad Abdolsaheb <ahmad.abdolsaheb@gmail.com>
Co-authored-by: Mrugesh Mohapatra <noreply@mrugesh.dev>
This commit is contained in:
Billy Arante
2023-07-05 23:04:49 +08:00
committed by GitHub
parent e10b025d26
commit b9cb12d24f
7 changed files with 493 additions and 2 deletions

View File

@@ -0,0 +1,22 @@
import React from 'react';
const CircleCheckRegular = (
props: JSX.IntrinsicAttributes & React.SVGProps<SVGSVGElement>
): JSX.Element => (
<svg
id='circle-check-regular'
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
xmlnsXlink='http://www.w3.org/1999/xlink'
{...props}
fill='var(--primary-color)'
height='24px'
width='24px'
>
<path d='M243.8 339.8C232.9 350.7 215.1 350.7 204.2 339.8L140.2 275.8C129.3 264.9 129.3 247.1 140.2 236.2C151.1 225.3 168.9 225.3 179.8 236.2L224 280.4L332.2 172.2C343.1 161.3 360.9 161.3 371.8 172.2C382.7 183.1 382.7 200.9 371.8 211.8L243.8 339.8zM512 256C512 397.4 397.4 512 256 512C114.6 512 0 397.4 0 256C0 114.6 114.6 0 256 0C397.4 0 512 114.6 512 256zM256 48C141.1 48 48 141.1 48 256C48 370.9 141.1 464 256 464C370.9 464 464 370.9 464 256C464 141.1 370.9 48 256 48z' />
</svg>
);
CircleCheckRegular.displayName = 'CircleCheckRegular';
export default CircleCheckRegular;

View File

@@ -0,0 +1,124 @@
import React from 'react';
import { createSelector } from 'reselect';
import { connect } from 'react-redux';
import { SuperBlocks } from '../../../../config/superblocks';
import {
isSignedInSelector,
userSelector,
userFetchStateSelector
} from '../../redux/selectors';
import CircleCheckRegular from './components/circle-check-regular';
interface FetchState {
pending: boolean;
complete: boolean;
errored: boolean;
}
interface User {
isRespWebDesignCert: boolean;
is2018DataVisCert: boolean;
isApisMicroservicesCert: boolean;
isBackEndCert: boolean;
isDataAnalysisPyCertV7: boolean;
isDataVisCert: boolean;
isFrontEndCert: boolean;
isFrontEndLibsCert: boolean;
isFullStackCert: boolean;
isInfosecCertV7: boolean;
isInfosecQaCert: boolean;
isJsAlgoDataStructCert: boolean;
isMachineLearningPyCertV7: boolean;
isQaCertV7: boolean;
isRelationalDatabaseCertV8: boolean;
isSciCompPyCertV7: boolean;
}
const mapStateToProps = createSelector(
userFetchStateSelector,
isSignedInSelector,
userSelector,
(fetchState: FetchState, isSignedIn: boolean, user: User) => ({
fetchState,
isSignedIn,
user
})
);
const certsMap = {
[SuperBlocks.RespWebDesignNew]: false,
[SuperBlocks.RespWebDesign]: false,
[SuperBlocks.JsAlgoDataStruct]: false,
[SuperBlocks.JsAlgoDataStructNew]: false,
[SuperBlocks.FrontEndDevLibs]: false,
[SuperBlocks.DataVis]: false,
[SuperBlocks.BackEndDevApis]: false,
[SuperBlocks.RelationalDb]: false,
[SuperBlocks.QualityAssurance]: false,
[SuperBlocks.SciCompPy]: false,
[SuperBlocks.DataAnalysisPy]: false,
[SuperBlocks.InfoSec]: false,
[SuperBlocks.MachineLearningPy]: false,
[SuperBlocks.CodingInterviewPrep]: false,
[SuperBlocks.TheOdinProject]: false,
[SuperBlocks.ProjectEuler]: false,
[SuperBlocks.CollegeAlgebraPy]: false,
[SuperBlocks.FoundationalCSharp]: false,
[SuperBlocks.ExampleCertification]: false
};
interface CertificateEarnedProps {
superBlock: SuperBlocks;
user: User;
}
const CertificateEarned = ({ superBlock, user }: CertificateEarnedProps) => {
// Update the values of certsMap with fetched user data
switch (superBlock) {
case SuperBlocks.RespWebDesign:
case SuperBlocks.RespWebDesignNew:
certsMap[superBlock] = user.isRespWebDesignCert;
break;
case SuperBlocks.JsAlgoDataStruct:
case SuperBlocks.JsAlgoDataStructNew:
certsMap[superBlock] = user.isJsAlgoDataStructCert;
break;
case SuperBlocks.FrontEndDevLibs:
certsMap[superBlock] = user.isFrontEndLibsCert;
break;
case SuperBlocks.DataVis:
certsMap[superBlock] = user.isDataVisCert;
break;
case SuperBlocks.BackEndDevApis:
certsMap[superBlock] = user.isBackEndCert;
break;
case SuperBlocks.RelationalDb:
certsMap[superBlock] = user.isRelationalDatabaseCertV8;
break;
case SuperBlocks.QualityAssurance:
certsMap[superBlock] = user.isQaCertV7;
break;
case SuperBlocks.SciCompPy:
certsMap[superBlock] = user.isSciCompPyCertV7;
break;
case SuperBlocks.DataAnalysisPy:
certsMap[superBlock] = user.isDataAnalysisPyCertV7;
break;
case SuperBlocks.InfoSec:
certsMap[superBlock] = user.isInfosecCertV7;
break;
case SuperBlocks.MachineLearningPy:
certsMap[superBlock] = user.isMachineLearningPyCertV7;
break;
default:
break;
}
return superBlock in certsMap && certsMap[superBlock] ? (
<CircleCheckRegular role='img' aria-label='earned' />
) : (
<></>
);
};
export default connect(mapStateToProps)(CertificateEarned);

View File

@@ -10,6 +10,7 @@ import { generateIconComponent } from '../../assets/icons';
import LinkButton from '../../assets/icons/link-button';
import { Link, Spacer } from '../helpers';
import { getSuperBlockTitleForMap } from '../../utils/superblock-map-titles';
import CertificateEarned from '../CertificateEarned';
import {
curriculumLocale,
showUpcomingChanges,
@@ -74,6 +75,7 @@ function MapLi({
{getSuperBlockTitleForMap(superBlock)}
</div>
{landing && <LinkButton />}
<CertificateEarned superBlock={superBlock} />
</Link>
</li>
</>

View File

@@ -0,0 +1,276 @@
import React from 'react';
import { graphql, useStaticQuery } from 'gatsby';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { ProgressBar } from '@freecodecamp/react-bootstrap';
import { certificatesByNameSelector } from '../../redux/selectors';
import type { CurrentCert } from '../../redux/prop-types';
import { SuperBlocks } from '../../../../config/superblocks';
import './progress-indicator.css';
type CompletedChallenge = {
id: string;
challengeType?: number;
};
type NodeChallenge = {
node: {
challenge: {
id: string;
};
};
};
type NodeData = {
allChallengeNode: {
totalCount: number;
};
allCertificateNode: {
totalCount: number;
};
};
interface ProgressIndicatorProps {
completedChallengeCount?: number;
completedChallengesList?: CompletedChallenge[];
pathname?: string;
superBlock?: SuperBlocks;
superBlockChallengesList?: NodeChallenge[];
superBlockTotalChallengesCount?: number;
superBlockTotalProjectsCount?: number;
currentCerts?: CurrentCert[];
legacyCerts?: CurrentCert[];
username: string;
}
const mapStateToProps = (
state: Record<string, unknown>,
props: ProgressIndicatorProps
) =>
createSelector(
certificatesByNameSelector(props.username.toLowerCase()),
({
currentCerts,
legacyCerts
}: Pick<ProgressIndicatorProps, 'currentCerts' | 'legacyCerts'>) => ({
currentCerts,
legacyCerts
})
)(state);
const ProgressIndicator = (props: ProgressIndicatorProps): JSX.Element => {
const {
completedChallengeCount = 0,
completedChallengesList = [],
pathname = '',
superBlock = '',
superBlockChallengesList = [],
superBlockTotalChallengesCount = 0,
superBlockTotalProjectsCount = 5,
currentCerts,
legacyCerts
} = props;
const isLearnPage = '/learn/'; // current path of main (learn) page
// Compute the earned current and legacy certificates
let earnedCertificateCount = 0;
currentCerts?.forEach((cert: { show: boolean }) => {
if (cert.show) {
earnedCertificateCount += 1;
}
});
legacyCerts?.forEach((cert: { show: boolean }) => {
if (cert.show) {
earnedCertificateCount += 1;
}
});
const data: NodeData = useStaticQuery(graphql`
query {
allChallengeNode {
totalCount
}
allCertificateNode {
totalCount
}
}
`);
/*
*
* Overall progress
*
*/
let allChallengeCount = 0;
let allCertificateCount = 0;
if (data) {
allChallengeCount = data.allChallengeNode.totalCount;
allCertificateCount = data.allCertificateNode.totalCount;
}
const computePercentage = ({ completed = 0, length = 0 } = {}): number => {
const result = (completed / length) * 100;
if (!result) {
return 0;
}
if (result < 1) {
return Number(result.toFixed(2));
}
return Math.floor(result);
};
const completedChallengePercentage = computePercentage({
completed: completedChallengeCount,
length: allChallengeCount
});
const completedCertificatePercentage = computePercentage({
completed: earnedCertificateCount,
length: allCertificateCount
});
/*
*
* Superblock progress
*
*/
const getCompletedChallengesCount = ({
completedChallengesList = [],
superBlockChallengesList = []
}: {
completedChallengesList: CompletedChallenge[];
superBlockChallengesList: NodeChallenge[];
}) => {
const completedChallengesIDs = completedChallengesList.map(challenge => {
return challenge.id;
});
const completedChallengeObjects = superBlockChallengesList.filter(
challenge => {
return completedChallengesIDs.includes(challenge.node.challenge.id);
}
);
return completedChallengeObjects.length;
};
const getSuperBlockCompletedProjectsCount = ({
completedChallengesList = []
}: {
completedChallengesList: CompletedChallenge[];
}) => {
// Collect the id of challenges with challengeType === 14.
// Not sure yet if all challenges with challengeType of 14 are all projects.
// Mark null to id (item) without the challengeType property.
const completedProjectObjects = completedChallengesList.map(challenge => {
return challenge.challengeType && challenge.challengeType === 14
? challenge.id
: null;
});
// Remove those items (id) which is null.
const completedProjectIDs = completedProjectObjects.filter(
id => id !== null
);
// Filter the projects for the current superblock.
const completedSuperBlockProjects = superBlockChallengesList.filter(
challenge => {
return completedProjectIDs.includes(challenge.node.challenge.id);
}
);
return completedSuperBlockProjects.length;
};
const superBlockCompletedChallengesCount = getCompletedChallengesCount({
completedChallengesList,
superBlockChallengesList
});
const superBlockCompletedChallengesPercent = computePercentage({
completed: superBlockCompletedChallengesCount,
length: superBlockTotalChallengesCount
});
const superBlockCompletedProjectsCount = getSuperBlockCompletedProjectsCount({
completedChallengesList
});
const superBlockCompletedProjectsPercent = computePercentage({
completed: superBlockCompletedProjectsCount,
length: superBlockTotalProjectsCount
});
return (
<div className='progress-summary'>
<h2 className='progress-summary__main-header'>
OverAll Progress Summary
</h2>
{pathname === isLearnPage && (
<section className='progress-summary__section'>
<section>
<div className='progress-summary__completed'>
<span>
{completedChallengeCount}/{allChallengeCount} challenges
completed
</span>
<span>{completedChallengePercentage}%</span>
</div>
<ProgressBar
aria-hidden='true'
now={completedChallengePercentage}
/>
</section>
<section>
<div className='progress-summary__completed'>
<span>
{earnedCertificateCount}/{allCertificateCount} certificates
earned
</span>
<span>{completedCertificatePercentage}%</span>
</div>
<ProgressBar
aria-hidden='true'
now={completedCertificatePercentage}
/>
</section>
</section>
)}
{superBlock !== '' && (
<section className='progress-summary__section'>
<section>
<div className='progress-summary__completed'>
<span>
{superBlockCompletedChallengesCount}/
{superBlockTotalChallengesCount} challenges completed
</span>
<span>{superBlockCompletedChallengesPercent}%</span>
</div>
<ProgressBar
aria-hidden='true'
now={superBlockCompletedChallengesCount}
/>
</section>
<section>
<div className='progress-summary__completed'>
<span>
{superBlockCompletedProjectsCount}/
{superBlockTotalProjectsCount} projects completed
</span>
<span>{superBlockCompletedProjectsPercent}%</span>
</div>
<ProgressBar
aria-hidden='true'
now={superBlockCompletedProjectsPercent}
/>
</section>
</section>
)}
</div>
);
};
export default connect(mapStateToProps)(ProgressIndicator);

View File

@@ -0,0 +1,38 @@
.progress-summary {
border: 3px solid var(--secondary-color);
padding: 16px;
margin-bottom: 10px;
}
.progress-summary__main-header {
text-align: center;
margin: 0;
}
.progress-summary,
.progress-summary__section {
display: grid;
row-gap: 16px;
}
.progress-summary__completed {
display: flex;
justify-content: space-between;
}
.progress {
box-shadow: inset 0 1px 2px rgb(0 0 0 / 10%);
height: 15px;
background-color: var(--primary-background);
margin: 0;
width: 100%;
border-radius: 0;
}
.progress-bar {
background-color: var(--blue-mid);
height: 100%;
font-size: 16px;
color: #fff;
box-shadow: inset 0 -1px 0 rgb(0 0 0 / 15%);
}

View File

@@ -9,6 +9,7 @@ import { bindActionCreators, Dispatch } from 'redux';
import Intro from '../components/Intro';
import Map from '../components/Map';
import ProgressIndicator from '../components/ProgressIndicator';
import { Spacer } from '../components/helpers';
import LearnLayout from '../components/layouts/learn';
import { defaultDonation } from '../../../config/donation-settings';
@@ -61,6 +62,7 @@ interface LearnPageProps {
};
};
};
path: string;
}
const mapDispatchToProps = (dispatch: Dispatch) =>
@@ -70,14 +72,20 @@ function LearnPage({
isSignedIn,
executeGA,
fetchState: { pending, complete },
user: { name = '', completedChallengeCount = 0, isDonating = false },
user: {
name = '',
completedChallengeCount = 0,
isDonating = false,
username = ''
},
data: {
challengeNode: {
challenge: {
fields: { slug }
}
}
}
},
path
}: LearnPageProps) {
const { t } = useTranslation();
@@ -89,6 +97,7 @@ function LearnPage({
amount: defaultDonation.donationAmount
});
};
return (
<LearnLayout>
<Helmet title={t('metaTags:title')} />
@@ -105,6 +114,14 @@ function LearnPage({
onDonationAlertClick={onDonationAlertClick}
isDonating={isDonating}
/>
{isSignedIn && (
<ProgressIndicator
completedChallengeCount={completedChallengeCount}
username={username}
pathname={path}
/>
)}
<h2 className='sr-only'>{t('settings.headings.certs')}</h2>
<Map />
<Spacer size='large' />
</Col>

View File

@@ -25,6 +25,7 @@ import {
signInLoadingSelector
} from '../../redux/selectors';
import { MarkdownRemark, AllChallengeNode, User } from '../../redux/prop-types';
import ProgressIndicator from '../../components/ProgressIndicator';
import Block from './components/block';
import CertChallenge from './components/cert-challenge';
import LegacyLinks from './components/legacy-links';
@@ -180,6 +181,9 @@ const SuperBlockIntroductionPage = (props: SuperBlockProp) => {
const i18nTitle = getSuperBlockTitleForMap(superBlock);
const defaultCurriculumNames = blockDashedNames;
const superBlockTotalChallengesCount = edges.length;
const superBlockChallengesList = edges;
const completedChallengesList = user.completedChallenges;
const superblockWithoutCert = [
SuperBlocks.CodingInterviewPrep,
SuperBlocks.TheOdinProject,
@@ -198,6 +202,14 @@ const SuperBlockIntroductionPage = (props: SuperBlockProp) => {
<Spacer size='large' />
<LegacyLinks superBlock={superBlock} />
<SuperBlockIntro superBlock={superBlock} />
<Spacer size={2} />
<ProgressIndicator
username={user.username}
superBlockTotalChallengesCount={superBlockTotalChallengesCount}
superBlock={superBlock}
superBlockChallengesList={superBlockChallengesList}
completedChallengesList={completedChallengesList}
/>
<Spacer size='large' />
<h2 className='text-center big-subheading'>
{t(`intro:misc-text.courses`)}