mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-04-19 04:00:56 -04:00
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:
@@ -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;
|
||||
124
client/src/components/CertificateEarned/index.tsx
Normal file
124
client/src/components/CertificateEarned/index.tsx
Normal 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);
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
276
client/src/components/ProgressIndicator/index.tsx
Normal file
276
client/src/components/ProgressIndicator/index.tsx
Normal 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);
|
||||
@@ -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%);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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`)}
|
||||
|
||||
Reference in New Issue
Block a user