diff --git a/client/src/components/CertificateEarned/components/circle-check-regular.tsx b/client/src/components/CertificateEarned/components/circle-check-regular.tsx new file mode 100644 index 00000000000..46d46f09ce1 --- /dev/null +++ b/client/src/components/CertificateEarned/components/circle-check-regular.tsx @@ -0,0 +1,22 @@ +import React from 'react'; + +const CircleCheckRegular = ( + props: JSX.IntrinsicAttributes & React.SVGProps +): JSX.Element => ( + + + +); + +CircleCheckRegular.displayName = 'CircleCheckRegular'; + +export default CircleCheckRegular; diff --git a/client/src/components/CertificateEarned/index.tsx b/client/src/components/CertificateEarned/index.tsx new file mode 100644 index 00000000000..283ae25cc10 --- /dev/null +++ b/client/src/components/CertificateEarned/index.tsx @@ -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] ? ( + + ) : ( + <> + ); +}; + +export default connect(mapStateToProps)(CertificateEarned); diff --git a/client/src/components/Map/index.tsx b/client/src/components/Map/index.tsx index ae7cc112be3..fbcc7e78736 100644 --- a/client/src/components/Map/index.tsx +++ b/client/src/components/Map/index.tsx @@ -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)} {landing && } + diff --git a/client/src/components/ProgressIndicator/index.tsx b/client/src/components/ProgressIndicator/index.tsx new file mode 100644 index 00000000000..64fe3376c16 --- /dev/null +++ b/client/src/components/ProgressIndicator/index.tsx @@ -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, + props: ProgressIndicatorProps +) => + createSelector( + certificatesByNameSelector(props.username.toLowerCase()), + ({ + currentCerts, + legacyCerts + }: Pick) => ({ + 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 ( +
+

+ OverAll Progress Summary +

+ {pathname === isLearnPage && ( +
+
+
+ + {completedChallengeCount}/{allChallengeCount} challenges + completed + + {completedChallengePercentage}% +
+
+
+
+ + {earnedCertificateCount}/{allCertificateCount} certificates + earned + + {completedCertificatePercentage}% +
+
+
+ )} + {superBlock !== '' && ( +
+
+
+ + {superBlockCompletedChallengesCount}/ + {superBlockTotalChallengesCount} challenges completed + + {superBlockCompletedChallengesPercent}% +
+
+
+
+ + {superBlockCompletedProjectsCount}/ + {superBlockTotalProjectsCount} projects completed + + {superBlockCompletedProjectsPercent}% +
+
+
+ )} +
+ ); +}; + +export default connect(mapStateToProps)(ProgressIndicator); diff --git a/client/src/components/ProgressIndicator/progress-indicator.css b/client/src/components/ProgressIndicator/progress-indicator.css new file mode 100644 index 00000000000..68ecd627302 --- /dev/null +++ b/client/src/components/ProgressIndicator/progress-indicator.css @@ -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%); +} diff --git a/client/src/pages/learn.tsx b/client/src/pages/learn.tsx index 65e7b3e5c02..5ebc2151e1f 100644 --- a/client/src/pages/learn.tsx +++ b/client/src/pages/learn.tsx @@ -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 ( @@ -105,6 +114,14 @@ function LearnPage({ onDonationAlertClick={onDonationAlertClick} isDonating={isDonating} /> + {isSignedIn && ( + + )} +

{t('settings.headings.certs')}

diff --git a/client/src/templates/Introduction/super-block-intro.tsx b/client/src/templates/Introduction/super-block-intro.tsx index 66aead727f7..12962654037 100644 --- a/client/src/templates/Introduction/super-block-intro.tsx +++ b/client/src/templates/Introduction/super-block-intro.tsx @@ -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) => { + +

{t(`intro:misc-text.courses`)}