diff --git a/api-server/src/server/boot/certificate.js b/api-server/src/server/boot/certificate.js
index 1557ea923c..74fa545d86 100644
--- a/api-server/src/server/boot/certificate.js
+++ b/api-server/src/server/boot/certificate.js
@@ -19,7 +19,8 @@ import {
certTypeTitleMap,
certTypeIdMap,
certIds,
- oldDataVizId
+ oldDataVizId,
+ superBlockCertTypeMap
} from '../../../../config/certification-settings';
const {
@@ -49,9 +50,11 @@ export default function bootCertificate(app) {
const certTypeIds = createCertTypeIds(getChallenges());
const showCert = createShowCert(app);
const verifyCert = createVerifyCert(certTypeIds, app);
+ const verifyCanClaimCert = createVerifyCanClaim(certTypeIds, app);
api.put('/certificate/verify', ifNoUser401, ifNoSuperBlock404, verifyCert);
api.get('/certificate/showCert/:username/:certSlug', showCert);
+ api.get('/certificate/verify-can-claim-cert', verifyCanClaimCert);
app.use(api);
}
@@ -494,3 +497,76 @@ function createShowCert(app) {
}, next);
};
}
+
+function createVerifyCanClaim(certTypeIds, app) {
+ const { User } = app.models;
+
+ function findUserByUsername$(username, fields) {
+ return observeQuery(User, 'findOne', {
+ where: { username },
+ fields
+ });
+ }
+ return function verifyCert(req, res, next) {
+ const { superBlock, username } = req.query;
+ log(superBlock);
+ let certType = superBlockCertTypeMap[superBlock];
+ log(certType);
+
+ return findUserByUsername$(username, {
+ isFrontEndCert: true,
+ isBackEndCert: true,
+ isFullStackCert: true,
+ isRespWebDesignCert: true,
+ isFrontEndLibsCert: true,
+ isJsAlgoDataStructCert: true,
+ isDataVisCert: true,
+ is2018DataVisCert: true,
+ isApisMicroservicesCert: true,
+ isInfosecQaCert: true,
+ isQaCertV7: true,
+ isInfosecCertV7: true,
+ isSciCompPyCertV7: true,
+ isDataAnalysisPyCertV7: true,
+ isMachineLearningPyCertV7: true,
+ username: true,
+ name: true,
+ isHonest: true,
+ completedChallenges: true
+ }).subscribe(user => {
+ return Observable.of(certTypeIds[certType])
+ .flatMap(challenge => {
+ const certName = certTypeTitleMap[certType];
+ const { tests = [] } = challenge;
+ const { isHonest, completedChallenges } = user;
+ const isProjectsCompleted = canClaim(tests, completedChallenges);
+ let result = 'incomplete-requirements';
+ let status = false;
+
+ if (isHonest && isProjectsCompleted) {
+ status = true;
+ result = 'requirements-met';
+ } else if (isProjectsCompleted) {
+ result = 'projects-completed';
+ } else if (isHonest) {
+ result = 'is-honest';
+ }
+ return Observable.just({
+ type: 'success',
+ message: { status, result },
+ variables: { name: certName }
+ });
+ })
+ .subscribe(message => {
+ return res.status(200).json({
+ response: message,
+ isCertMap: getUserIsCertMap(user),
+ // send back the completed challenges
+ // NOTE: we could just send back the latest challenge, but this
+ // ensures the challenges are synced.
+ completedChallenges: user.completedChallenges
+ });
+ }, next);
+ });
+ };
+}
diff --git a/client/i18n/locales/english/translations.json b/client/i18n/locales/english/translations.json
index bddad18243..26373061c0 100644
--- a/client/i18n/locales/english/translations.json
+++ b/client/i18n/locales/english/translations.json
@@ -584,4 +584,4 @@
"add-code-two": "Please leave the ``` line above and the ``` line below,",
"add-code-three": "because they allow your code to properly format in the post."
}
-}
+}
\ No newline at end of file
diff --git a/client/package-lock.json b/client/package-lock.json
index 3f5cac070f..0142b9b3a6 100644
--- a/client/package-lock.json
+++ b/client/package-lock.json
@@ -5582,6 +5582,15 @@
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.1.0.tgz",
"integrity": "sha512-1Yj8h9Q+QDF5FzhMs/c9+6UntbD5MkRfRwac8DoEm9ZfUBZ7tZ55YcGVAzEe4bXsdQHEk+s9S5wsOKVdZrw0tQ=="
},
+ "bindings": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
+ "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
+ "optional": true,
+ "requires": {
+ "file-uri-to-path": "1.0.0"
+ }
+ },
"bl": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
@@ -9468,6 +9477,12 @@
"token-types": "^2.0.0"
}
},
+ "file-uri-to-path": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
+ "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
+ "optional": true
+ },
"filesize": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/filesize/-/filesize-6.1.0.tgz",
@@ -14836,6 +14851,12 @@
"resolved": "https://registry.npmjs.org/name-all-modules-plugin/-/name-all-modules-plugin-1.0.1.tgz",
"integrity": "sha1-Cr+2rYNXGLn7Te8GdOBmV6lUN1w="
},
+ "nan": {
+ "version": "2.14.2",
+ "resolved": "https://registry.npmjs.org/nan/-/nan-2.14.2.tgz",
+ "integrity": "sha512-M2ufzIiINKCuDfBSAUr1vWQ+vuVcA9kqx8JJUsbQi6yf1uGRyb7HfpdfUr5qLXf3B/t8dPvcjhKMmlfnP47EzQ==",
+ "optional": true
+ },
"nanoid": {
"version": "3.1.23",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.1.23.tgz",
@@ -20766,7 +20787,11 @@
"version": "1.2.13",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.13.tgz",
"integrity": "sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==",
- "optional": true
+ "optional": true,
+ "requires": {
+ "bindings": "^1.5.0",
+ "nan": "^2.12.1"
+ }
},
"glob-parent": {
"version": "3.1.0",
diff --git a/client/package.json b/client/package.json
index 86863f51a0..db5fd2ca9a 100644
--- a/client/package.json
+++ b/client/package.json
@@ -136,6 +136,7 @@
"@types/loadable__component": "5.13.4",
"@types/lodash-es": "4.17.4",
"@types/prismjs": "1.16.6",
+ "@types/reach__router": "^1.3.8",
"@types/react-dom": "17.0.9",
"@types/react-helmet": "6.1.2",
"@types/react-instantsearch-dom": "6.10.2",
@@ -162,4 +163,4 @@
"webpack": "5.44.0",
"webpack-cli": "4.7.2"
}
-}
\ No newline at end of file
+}
diff --git a/client/src/components/Donation/DonationModal.js b/client/src/components/Donation/DonationModal.js
index 984ee544e4..12bbbbfd1b 100644
--- a/client/src/components/Donation/DonationModal.js
+++ b/client/src/components/Donation/DonationModal.js
@@ -11,6 +11,8 @@ import Cup from '../../assets/icons/cup';
import DonateForm from './DonateForm';
import { modalDefaultDonation } from '../../../../config/donation-settings';
import { useTranslation } from 'react-i18next';
+import { goToAnchor } from 'react-scrollable-anchor';
+import { isLocationSuperBlock } from '../../utils/path-parsers';
import {
closeDonationModal,
@@ -43,6 +45,10 @@ const propTypes = {
activeDonors: PropTypes.number,
closeDonationModal: PropTypes.func.isRequired,
executeGA: PropTypes.func,
+ location: PropTypes.shape({
+ hash: PropTypes.string,
+ pathname: PropTypes.string
+ }),
recentlyClaimedBlock: PropTypes.string,
show: PropTypes.bool
};
@@ -51,6 +57,7 @@ function DonateModal({
show,
closeDonationModal,
executeGA,
+ location,
recentlyClaimedBlock
}) {
const [closeLabel, setCloseLabel] = React.useState(false);
@@ -98,6 +105,13 @@ function DonateModal({
}
};
+ const handleModalHide = () => {
+ // If modal is open on a SuperBlock page
+ if (isLocationSuperBlock(location)) {
+ goToAnchor('claim-cert-block');
+ }
+ };
+
const blockDonationText = (
@@ -131,7 +145,12 @@ function DonateModal({
);
return (
-
+
{recentlyClaimedBlock ? blockDonationText : progressDonationText}
diff --git a/client/src/redux/index.js b/client/src/redux/index.js
index 38fff44057..d00dbe3b53 100644
--- a/client/src/redux/index.js
+++ b/client/src/redux/index.js
@@ -176,6 +176,19 @@ export const completedChallengesSelector = state =>
userSelector(state).completedChallenges || [];
export const completionCountSelector = state => state[ns].completionCount;
export const currentChallengeIdSelector = state => state[ns].currentChallengeId;
+export const stepsToClaimSelector = state => {
+ const user = userSelector(state);
+ const currentCerts = certificatesByNameSelector(user.username)(
+ state
+ ).currentCerts;
+ return {
+ currentCerts: currentCerts,
+ isHonest: user?.isHonest,
+ isShowName: user?.profileUI?.showName,
+ isShowCerts: user?.profileUI?.showCerts,
+ isShowProfile: !user?.profileUI?.isLocked
+ };
+};
export const isDonatingSelector = state => userSelector(state).isDonating;
export const isOnlineSelector = state => state[ns].isOnline;
export const isSignedInSelector = state => !!state[ns].appUsername;
diff --git a/client/src/redux/prop-types.ts b/client/src/redux/prop-types.ts
index 153de57ea0..b07619b68b 100644
--- a/client/src/redux/prop-types.ts
+++ b/client/src/redux/prop-types.ts
@@ -134,6 +134,13 @@ export const CurrentCertsType = PropTypes.arrayOf(
})
);
+export const StepsType = PropTypes.shape({
+ currentCerts: CurrentCertsType,
+ isShowCerts: PropTypes.bool,
+ isShowName: PropTypes.bool,
+ isShowProfile: PropTypes.bool
+});
+
// TYPESCRIPT TYPES
export type CurrentCertType = {
diff --git a/client/src/templates/Challenges/redux/completion-epic.js b/client/src/templates/Challenges/redux/completion-epic.js
index a7d0854495..699d353fc6 100644
--- a/client/src/templates/Challenges/redux/completion-epic.js
+++ b/client/src/templates/Challenges/redux/completion-epic.js
@@ -5,7 +5,7 @@ import {
catchError,
concat,
filter,
- tap
+ finalize
} from 'rxjs/operators';
import { ofType } from 'redux-observable';
import { navigate } from 'gatsby';
@@ -24,11 +24,13 @@ import {
isSignedInSelector,
submitComplete,
updateComplete,
- updateFailed
+ updateFailed,
+ usernameSelector
} from '../../../redux';
import postUpdate$ from '../utils/postUpdate$';
import { challengeTypes, submitTypes } from '../../../../utils/challengeTypes';
+import { getVerifyCanClaimCert } from '../../../utils/ajax';
function postChallenge(update, username) {
const saveChallenge = postUpdate$(update).pipe(
@@ -133,7 +135,7 @@ export default function completionEpic(action$, state$) {
switchMap(({ type }) => {
const state = state$.value;
const meta = challengeMetaSelector(state);
- const { nextChallengePath, challengeType } = meta;
+ const { nextChallengePath, challengeType, superBlock } = meta;
const closeChallengeModal = of(closeModal('completion'));
let submitter = () => of({ type: 'no-user-signed-in' });
@@ -150,11 +152,55 @@ export default function completionEpic(action$, state$) {
submitter = submitters[submitTypes[challengeType]];
}
+ const pathToNavigateTo = async () => {
+ return await findPathToNavigateTo(
+ nextChallengePath,
+ superBlock,
+ state,
+ challengeType
+ );
+ };
+
return submitter(type, state).pipe(
- tap(() => navigate(nextChallengePath)),
concat(closeChallengeModal),
- filter(Boolean)
+ filter(Boolean),
+ finalize(async () => navigate(await pathToNavigateTo()))
);
})
);
}
+
+async function findPathToNavigateTo(
+ nextChallengePath,
+ superBlock,
+ state,
+ challengeType
+) {
+ let canClaimCert = false;
+ const isProjectSubmission = [
+ challengeTypes.frontEndProject,
+ challengeTypes.backEndProject,
+ challengeTypes.pythonProject
+ ].includes(challengeType);
+ if (isProjectSubmission) {
+ const username = usernameSelector(state);
+ try {
+ const response = await getVerifyCanClaimCert(username, superBlock);
+ if (response.status === 200) {
+ canClaimCert = response.data?.response?.message === 'can-claim-cert';
+ }
+ } catch (err) {
+ console.error('failed to verify if user can claim certificate', err);
+ }
+ }
+ let pathToNavigateTo;
+
+ if (nextChallengePath.includes(superBlock) && !canClaimCert) {
+ pathToNavigateTo = nextChallengePath;
+ } else if (canClaimCert) {
+ pathToNavigateTo = `/learn/${superBlock}/#claim-cert-block`;
+ } else {
+ pathToNavigateTo = `/learn/${superBlock}/#${superBlock}-projects`;
+ }
+ return pathToNavigateTo;
+}
diff --git a/client/src/templates/Introduction/SuperBlockIntro.js b/client/src/templates/Introduction/SuperBlockIntro.js
index 5931880506..cd66cb9caa 100644
--- a/client/src/templates/Introduction/SuperBlockIntro.js
+++ b/client/src/templates/Introduction/SuperBlockIntro.js
@@ -1,4 +1,4 @@
-import React, { Component, Fragment } from 'react';
+import React, { Fragment, useEffect, memo } from 'react';
import PropTypes from 'prop-types';
import Helmet from 'react-helmet';
import { graphql } from 'gatsby';
@@ -15,11 +15,13 @@ import Map from '../../components/Map';
import CertChallenge from './components/CertChallenge';
import SuperBlockIntro from './components/SuperBlockIntro';
import Block from './components/Block';
+import DonateModal from '../../../../client/src/components/Donation/DonationModal';
import { Spacer } from '../../components/helpers';
import {
currentChallengeIdSelector,
userFetchStateSelector,
isSignedInSelector,
+ tryToShowDonationModal,
userSelector
} from '../../redux';
import { resetExpansion, toggleBlock } from './redux';
@@ -42,6 +44,7 @@ const propTypes = {
isSignedIn: PropTypes.bool,
location: PropTypes.shape({
hash: PropTypes.string,
+ // TODO: state is sometimes a string
state: PropTypes.shape({
breadcrumbBlockClick: PropTypes.string
})
@@ -49,6 +52,7 @@ const propTypes = {
resetExpansion: PropTypes.func,
t: PropTypes.func,
toggleBlock: PropTypes.func,
+ tryToShowDonationModal: PropTypes.func.isRequired,
user: User
};
@@ -71,24 +75,30 @@ const mapStateToProps = state => {
const mapDispatchToProps = dispatch =>
bindActionCreators(
- { resetExpansion, toggleBlock: b => toggleBlock(b) },
+ {
+ tryToShowDonationModal,
+ resetExpansion,
+ toggleBlock: b => toggleBlock(b)
+ },
dispatch
);
-class SuperBlockIntroductionPage extends Component {
- componentDidMount() {
- this.initializeExpandedState();
+const SuperBlockIntroductionPage = props => {
+ useEffect(() => {
+ initializeExpandedState();
+ props.tryToShowDonationModal();
setTimeout(() => {
configureAnchors({ offset: -40, scrollDuration: 400 });
}, 0);
- }
- componentWillUnmount() {
- configureAnchors({ offset: -40, scrollDuration: 0 });
- }
+ return () => {
+ configureAnchors({ offset: -40, scrollDuration: 0 });
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
- getChosenBlock() {
+ const getChosenBlock = () => {
const {
data: {
allChallengeNode: { edges }
@@ -96,7 +106,7 @@ class SuperBlockIntroductionPage extends Component {
isSignedIn,
currentChallengeId,
location
- } = this.props;
+ } = props;
// if coming from breadcrumb click
if (location.state && location.state.breadcrumbBlockClick) {
@@ -123,100 +133,98 @@ class SuperBlockIntroductionPage extends Component {
}
return edge.node.block;
- }
+ };
- initializeExpandedState() {
- const { resetExpansion, toggleBlock } = this.props;
+ const initializeExpandedState = () => {
+ const { resetExpansion, toggleBlock } = props;
resetExpansion();
- return toggleBlock(this.getChosenBlock());
- }
+ return toggleBlock(getChosenBlock());
+ };
- render() {
- const {
- data: {
- markdownRemark: {
- frontmatter: { superBlock, title }
- },
- allChallengeNode: { edges }
+ const {
+ data: {
+ markdownRemark: {
+ frontmatter: { superBlock, title }
},
- isSignedIn,
- t,
- user
- } = this.props;
+ allChallengeNode: { edges }
+ },
+ isSignedIn,
+ t,
+ user
+ } = props;
- const nodesForSuperBlock = edges.map(({ node }) => node);
- const blockDashedNames = uniq(nodesForSuperBlock.map(({ block }) => block));
- const i18nSuperBlock = t(`intro:${superBlock}.title`);
- const i18nTitle =
- superBlock === 'coding-interview-prep'
- ? i18nSuperBlock
- : t(`intro:misc-text.certification`, {
- cert: i18nSuperBlock
- });
+ const nodesForSuperBlock = edges.map(({ node }) => node);
+ const blockDashedNames = uniq(nodesForSuperBlock.map(({ block }) => block));
+ const i18nSuperBlock = t(`intro:${superBlock}.title`);
+ const i18nTitle =
+ superBlock === 'coding-interview-prep'
+ ? i18nSuperBlock
+ : t(`intro:misc-text.certification`, {
+ cert: i18nSuperBlock
+ });
- return (
- <>
-
- {i18nTitle} | freeCodeCamp.org
-
-
-
-
-
-
-
-
- {t(`intro:misc-text.courses`)}
-
-
-
- {blockDashedNames.map(blockDashedName => (
-
- node.block === blockDashedName
- )}
- superBlock={superBlock}
- />
- {blockDashedName !== 'project-euler' ? : null}
-
- ))}
- {superBlock !== 'coding-interview-prep' && (
-
-
-
- )}
-
- {!isSignedIn && (
+ return (
+ <>
+
+ {i18nTitle} | freeCodeCamp.org
+
+
+
+
+
+
+
+
+ {t(`intro:misc-text.courses`)}
+
+
+
+ {blockDashedNames.map(blockDashedName => (
+
+ node.block === blockDashedName
+ )}
+ superBlock={superBlock}
+ />
+ {blockDashedName !== 'project-euler' ? : null}
+
+ ))}
+ {superBlock !== 'coding-interview-prep' && (
-
- {t('buttons.logged-out-cta-btn')}
+
)}
-
-
- {t(`intro:misc-text.browse-other`)}
-
-
-
-
-
-
-
- >
- );
- }
-}
+
+ {!isSignedIn && (
+
+
+ {t('buttons.logged-out-cta-btn')}
+
+ )}
+
+
+ {t(`intro:misc-text.browse-other`)}
+
+
+
+
+
+
+
+
+ >
+ );
+};
SuperBlockIntroductionPage.displayName = 'SuperBlockIntroductionPage';
SuperBlockIntroductionPage.propTypes = propTypes;
@@ -224,7 +232,7 @@ SuperBlockIntroductionPage.propTypes = propTypes;
export default connect(
mapStateToProps,
mapDispatchToProps
-)(withTranslation()(SuperBlockIntroductionPage));
+)(withTranslation()(memo(SuperBlockIntroductionPage)));
export const query = graphql`
query SuperBlockIntroPageBySlug($slug: String!, $superBlock: String!) {
diff --git a/client/src/templates/Introduction/components/CertChallenge.js b/client/src/templates/Introduction/components/CertChallenge.js
index 95a6b56068..762c270fbe 100644
--- a/client/src/templates/Introduction/components/CertChallenge.js
+++ b/client/src/templates/Introduction/components/CertChallenge.js
@@ -1,97 +1,154 @@
-import React, { Component } from 'react';
+import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
-import { navigate } from 'gatsby';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { withTranslation } from 'react-i18next';
+import { Button } from '@freecodecamp/react-bootstrap';
+
+import CertificationCard from './CertificationCard';
+
+import { stepsToClaimSelector } from '../../../redux';
+import { verifyCert } from '../../../redux/settings';
+import { createFlashMessage } from '../../../components/Flash/redux';
+import { StepsType, User } from '../../../redux/prop-types';
-import CertificationIcon from '../../../assets/icons/certification-icon';
-import GreenPass from '../../../assets/icons/green-pass';
-import GreenNotCompleted from '../../../assets/icons/green-not-completed';
-import { certificatesByNameSelector } from '../../../redux';
-import { CurrentCertsType, User } from '../../../redux/prop-types';
import { certMap } from '../../../resources/cert-and-project-map';
import {
certSlugTypeMap,
superBlockCertTypeMap
} from '../../../../../config/certification-settings';
-import CertificationCard from './CertificationCard';
+import { getVerifyCanClaimCert } from '../../../utils/ajax';
+import { navigate } from 'gatsby-link';
const propTypes = {
- currentCerts: CurrentCertsType,
- isSignedIn: PropTypes.bool.isRequired,
+ createFlashMessage: PropTypes.func.isRequired,
+ steps: StepsType,
superBlock: PropTypes.string,
t: PropTypes.func,
title: PropTypes.string,
- user: User
+ user: User,
+ verifyCert: PropTypes.func.isRequired
};
-const mapStateToProps = (state, props) => {
- return createSelector(
- certificatesByNameSelector(props.user.username),
- ({ currentCerts }) => ({
- currentCerts
- })
- )(state, props);
+const honestyInfoMessage = {
+ type: 'info',
+ message: 'flash.honest-first'
};
-export class CertChallenge extends Component {
- render() {
- const {
- isSignedIn,
- superBlock,
- t,
- title,
- user: { username },
- currentCerts
- } = this.props;
+const mapStateToProps = state => {
+ return createSelector(stepsToClaimSelector, steps => ({
+ steps
+ }))(state);
+};
- const cert = certMap.find(x => x.title === title);
- const isCertified = currentCerts.find(
- cert =>
- certSlugTypeMap[cert.certSlug] === superBlockCertTypeMap[superBlock]
- ).show;
- const certLocation = `/certification/${username}/${cert.certSlug}`;
- const certCheckmarkStyle = { height: '40px', width: '40px' };
- const i18nSuperBlock = t(`intro:${superBlock}.title`);
- const i18nCertText = t(`intro:misc-text.certification`, {
- cert: i18nSuperBlock
- });
- const { certSlug } = cert;
+const mapDispatchToProps = {
+ createFlashMessage,
+ verifyCert
+};
- return (
-
- {isSignedIn && !isCertified && (
-
- )}
-
-
+const CertChallenge = ({
+ createFlashMessage,
+ steps = {},
+ superBlock,
+ t,
+ verifyCert,
+ title,
+ user: { isHonest, username }
+}) => {
+ const [canClaim, setCanClaim] = useState({ status: false, result: '' });
+ const [isCertified, setIsCertified] = useState(false);
+ const [stepState, setStepState] = useState({
+ numberOfSteps: 0,
+ completedCount: 0
+ });
+ const [canViewCert, setCanViewCert] = useState(false);
+ const [isProjectsCompleted, setIsProjectsCompleted] = useState(false);
+
+ useEffect(() => {
+ if (username) {
+ (async () => {
+ try {
+ const data = await getVerifyCanClaimCert(username, superBlock);
+ const { status, result } = data?.response?.message;
+ setCanClaim({ status, result });
+ } catch (e) {
+ // TODO: How do we handle errors...?
+ }
+ })();
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [username]);
+
+ const { certSlug } = certMap.find(x => x.title === title);
+
+ useEffect(() => {
+ setIsCertified(
+ steps?.currentCerts?.find(
+ cert =>
+ certSlugTypeMap[cert.certSlug] === superBlockCertTypeMap[superBlock]
+ )?.show ?? false
);
- }
-}
+
+ const projectsCompleted =
+ canClaim.status || canClaim.result === 'projects-completed';
+ const completedCount =
+ Object.values(steps).filter(
+ stepVal => typeof stepVal === 'boolean' && stepVal
+ ).length + projectsCompleted;
+ const numberOfSteps = Object.keys(steps).length;
+
+ setCanViewCert(completedCount === numberOfSteps);
+ setStepState({ numberOfSteps, completedCount });
+ setIsProjectsCompleted(projectsCompleted);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [steps, canClaim]);
+
+ const certLocation = `/certification/${username}/${certSlug}`;
+ const i18nSuperBlock = t(`intro:${superBlock}.title`);
+ const i18nCertText = t(`intro:misc-text.certification`, {
+ cert: i18nSuperBlock
+ });
+
+ const createClickHandler = certSlug => e => {
+ e.preventDefault();
+ if (isCertified) {
+ return navigate(certLocation);
+ }
+ return isHonest
+ ? verifyCert(certSlug)
+ : createFlashMessage(honestyInfoMessage);
+ };
+
+ return (
+
+ {(!isCertified || !canViewCert) && (
+
+ )}
+
+
+ );
+};
CertChallenge.displayName = 'CertChallenge';
CertChallenge.propTypes = propTypes;
-export default connect(mapStateToProps)(withTranslation()(CertChallenge));
+export { CertChallenge };
+
+export default connect(
+ mapStateToProps,
+ mapDispatchToProps
+)(withTranslation()(CertChallenge));
diff --git a/client/src/templates/Introduction/components/CertificationCard.js b/client/src/templates/Introduction/components/CertificationCard.js
index d2ed807e26..8660c66ef0 100644
--- a/client/src/templates/Introduction/components/CertificationCard.js
+++ b/client/src/templates/Introduction/components/CertificationCard.js
@@ -1,18 +1,35 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
-import ScrollableAnchor from 'react-scrollable-anchor';
-import { useTranslation } from 'react-i18next';
+import GreenNotCompleted from '../../../assets/icons/green-not-completed';
+import ScrollableAnchor from 'react-scrollable-anchor';
+// import { navigate } from 'gatsby';
+import { useTranslation } from 'react-i18next';
import ClaimCertSteps from './ClaimCertSteps';
+import GreenPass from '../../../assets/icons/green-pass';
+import { StepsType } from '../../../redux/prop-types';
import Caret from '../../../assets/icons/caret';
const propTypes = {
- certSlug: PropTypes.string,
i18nCertText: PropTypes.string,
+ isProjectsCompleted: PropTypes.bool,
+ stepState: PropTypes.shape({
+ numberOfSteps: PropTypes.number,
+ completedCount: PropTypes.number
+ }),
+ steps: StepsType,
superBlock: PropTypes.string
};
-const CertificationCard = ({ certSlug, superBlock, i18nCertText }) => {
+const mapIconStyle = { height: '15px', marginRight: '10px', width: '15px' };
+
+const CertificationCard = ({
+ isProjectsCompleted,
+ superBlock,
+ i18nCertText,
+ stepState: { completedCount, numberOfSteps },
+ steps
+}) => {
const { t } = useTranslation();
const [isExpanded, setIsExpanded] = useState(true);
@@ -23,7 +40,7 @@ const CertificationCard = ({ certSlug, superBlock, i18nCertText }) => {
const {
expand: expandText,
collapse: collapseText,
- steps: stepsText
+ courses: coursesText
} = t('intro:misc-text');
return (
@@ -48,13 +65,22 @@ const CertificationCard = ({ certSlug, superBlock, i18nCertText }) => {
{`${
isExpanded ? collapseText : expandText
- } ${stepsText.toLowerCase()}`}
+ } ${coursesText.toLowerCase()}`}
+
+ {completedCount === numberOfSteps ? (
+
+ ) : (
+
+ )}
+ {`${completedCount}/${numberOfSteps}`}
+
{isExpanded && (
)}
diff --git a/client/src/templates/Introduction/components/ClaimCertSteps.js b/client/src/templates/Introduction/components/ClaimCertSteps.js
index 16995f83a5..f5ac8be78d 100644
--- a/client/src/templates/Introduction/components/ClaimCertSteps.js
+++ b/client/src/templates/Introduction/components/ClaimCertSteps.js
@@ -2,100 +2,81 @@ import React from 'react';
import PropTypes from 'prop-types';
import { Link } from 'gatsby';
import { withTranslation, useTranslation } from 'react-i18next';
-import { connect } from 'react-redux';
-import { createSelector } from 'reselect';
-import IntroInformation from '../../../assets/icons/intro-information';
+import { StepsType } from '../../../redux/prop-types';
import GreenPass from '../../../assets/icons/green-pass';
import GreenNotCompleted from '../../../assets/icons/green-not-completed';
-import { userSelector } from '../../../redux';
-import { User } from '../../../redux/prop-types';
const mapIconStyle = { height: '15px', marginRight: '10px', width: '15px' };
-const renderCheckMark = isCompleted => {
- return isCompleted ? (
-
- ) : (
-
- );
-};
const propTypes = {
- certSlug: PropTypes.string,
i18nCertText: PropTypes.string,
- superBlock: PropTypes.string,
- user: User
+ isProjectsCompleted: PropTypes.bool,
+ steps: StepsType,
+ superBlock: PropTypes.string
};
-const mapStateToProps = state => {
- return createSelector(userSelector, user => ({
- user
- }))(state);
-};
-
-const ClaimCertSteps = ({ certSlug, i18nCertText, superBlock, user }) => {
+const ClaimCertSteps = ({
+ isProjectsCompleted,
+ i18nCertText,
+ steps,
+ superBlock
+}) => {
const { t } = useTranslation();
+ const renderCheckMark = isCompleted => {
+ return isCompleted ? (
+
+ ) : (
+
+ );
+ };
const settingsLink = '/settings#privacy-settings';
- const certClaimLink = `/settings#cert-${certSlug}`;
const honestyPolicyAnchor = '/settings#honesty-policy';
-
const {
- name,
- isHonest,
- profileUI: { isLocked, showCerts, showName }
- } = user;
-
+ isHonest = false,
+ isShowName = false,
+ isShowCerts = false,
+ isShowProfile = false
+ } = steps;
return (
-
- -
+
+ -
+ {renderCheckMark(isHonest)}
{t('certification-card.accept-honesty')}
-
- {renderCheckMark(isHonest)}
-
- -
-
- {t('certification-card.set-profile-public')}
-
- {renderCheckMark(!isLocked)}
-
-
-
- -
-
- {t('certification-card.set-certs-public')}
-
- {renderCheckMark(showCerts)}
-
-
-
- -
-
- {t('certification-card.set-name')}
-
- {renderCheckMark(name && name !== '' && showName)}
-
-
-
- -
+
-
+
+ {renderCheckMark(isProjectsCompleted)}
+
{t('certification-card.complete-project', {
i18nCertText
})}
-
-
-
- -
-
- {t('certification-card.set-claim')}
-
-
+
-
+
+
+ {renderCheckMark(isShowProfile)}
+ {t('certification-card.set-profile-public')}
+
+
+ -
+
+
+ {renderCheckMark(isShowCerts)}
+
+ {t('certification-card.set-certs-public')}
+
+
+ -
+
+ {renderCheckMark(isShowName)}
+ {t('certification-card.set-name')}
@@ -105,4 +86,4 @@ const ClaimCertSteps = ({ certSlug, i18nCertText, superBlock, user }) => {
ClaimCertSteps.displayName = 'ClaimCertSteps';
ClaimCertSteps.propTypes = propTypes;
-export default connect(mapStateToProps)(withTranslation()(ClaimCertSteps));
+export default withTranslation()(ClaimCertSteps);
diff --git a/client/src/utils/ajax.ts b/client/src/utils/ajax.ts
index 0c0732f578..fb10bb1bb7 100644
--- a/client/src/utils/ajax.ts
+++ b/client/src/utils/ajax.ts
@@ -82,6 +82,16 @@ export function getUsernameExists(username: string): Promise {
return get(`/api/users/exists?username=${username}`);
}
+// TODO: Does a GET return a bolean?
+export function getVerifyCanClaimCert(
+ username: string,
+ superBlock: string
+): Promise {
+ return get(
+ `/certificate/verify-can-claim-cert?username=${username}&superBlock=${superBlock}`
+ );
+}
+
/** POST **/
interface Donation {
diff --git a/client/src/utils/path-parsers.ts b/client/src/utils/path-parsers.ts
index 6a77fb1309..bf15184d69 100644
--- a/client/src/utils/path-parsers.ts
+++ b/client/src/utils/path-parsers.ts
@@ -1,3 +1,4 @@
+import { WindowLocation } from '@reach/router';
import { i18nConstants } from '../../../config/constants';
const splitPath = (pathname: string): string[] =>
@@ -19,5 +20,11 @@ export const isLanding = (pathname: string): boolean => {
return isEnglishLanding || isI18Landing;
};
+export const isLocationSuperBlock = (
+ location: WindowLocation | undefined
+): boolean => {
+ return /^\/learn\/[\w-]+\/$/.test(location?.pathname ?? '');
+};
+
const pathParsers = { isLanding, isChallenge };
export default pathParsers;
diff --git a/cypress/integration/ShowCertification.js b/cypress/integration/ShowCertification.js
index 263c3e4f74..fb191ec86a 100644
--- a/cypress/integration/ShowCertification.js
+++ b/cypress/integration/ShowCertification.js
@@ -45,7 +45,7 @@ describe('A certification,', function () {
.click();
cy.contains('Submit and go to next challenge').click().wait(1000);
});
- cy.get('.react-monaco-editor-container', { timeout: 60000 });
+ cy.get('.donation-modal').should('be.visible');
cy.visit('/settings');
// set user settings to public to claim a cert
diff --git a/cypress/integration/learn/responsive-web-design/claim-cert-from-learn.js b/cypress/integration/learn/responsive-web-design/claim-cert-from-learn.js
new file mode 100644
index 0000000000..00443fc6a4
--- /dev/null
+++ b/cypress/integration/learn/responsive-web-design/claim-cert-from-learn.js
@@ -0,0 +1,89 @@
+/* global cy */
+
+const projects = {
+ superBlock: 'responsive-web-design',
+ block: 'responsive-web-design-projects',
+ challenges: [
+ {
+ slug: 'build-a-tribute-page',
+ solution: 'https://codepen.io/moT01/pen/ZpJpKp'
+ },
+ {
+ slug: 'build-a-survey-form',
+ solution: 'https://codepen.io/moT01/pen/LrrjGz?editors=1010'
+ },
+ {
+ slug: 'build-a-product-landing-page',
+ solution: 'https://codepen.io/moT01/full/qKyKYL/'
+ },
+ {
+ slug: 'build-a-technical-documentation-page',
+ solution: 'https://codepen.io/moT01/full/JBvzNL/'
+ },
+ {
+ slug: 'build-a-personal-portfolio-webpage',
+ solution: 'https://codepen.io/moT01/pen/vgOaoJ'
+ }
+ ]
+};
+
+describe('Responsive Web Design Superblock', () => {
+ before(() => {
+ cy.exec('npm run seed');
+ cy.login();
+ cy.visit('/learn/responsive-web-design');
+ });
+ describe('Before submitting projects', () => {
+ it('should have a card with href "claim-cert-block"', () => {
+ cy.get('a[href="#claim-cert-block"]').scrollIntoView();
+ cy.get('a[href="#claim-cert-block"]').should('be.visible');
+ });
+
+ it('should have an anchor element with the text "Claim Certification", and class "disabled"', () => {
+ cy.get('a.disabled').should('be.visible');
+ cy.get('a.disabled').should('have.text', 'Claim Certification');
+ });
+
+ it('should have an unordered list with class "map-challenges-ul" containing 5 items', () => {
+ cy.get('[data-cy=claim-cert-steps]').should('be.visible');
+ cy.get('[data-cy=claim-cert-steps]').children().should('have.length', 5);
+ });
+ });
+ describe('After submitting all 5 projects', () => {
+ before(() => {
+ cy.toggleAll();
+ const { superBlock, block, challenges } = projects;
+ challenges.forEach(({ slug, solution }) => {
+ const url = `/learn/${superBlock}/${block}/${slug}`;
+ cy.visit(url);
+ cy.get('#dynamic-front-end-form')
+ .get('#solution')
+ .type(solution, { force: true, delay: 0 });
+ cy.contains("I've completed this challenge")
+ .should('not.be.disabled')
+ .click();
+ cy.contains('Submit and go to next challenge').click();
+ cy.location().should(loc => {
+ expect(loc.pathname).to.not.eq(url);
+ });
+ });
+ });
+ it('should be possible to claim and view certifications from the superBlock page', () => {
+ cy.location().should(loc => {
+ expect(loc.pathname).to.eq(`/learn/${projects.superBlock}/`);
+ });
+ cy.get('.donation-modal').should('be.visible');
+ cy.contains('Ask me later').click();
+ cy.get('.donation-modal').should('not.be.visible');
+ // directed to claim-cert-block section
+ cy.url().should('include', '#claim-cert-block');
+ cy.contains('Claim Certification').should('not.be.disabled').click();
+ cy.contains('Show Certification').click();
+ cy.location().should(loc => {
+ expect(loc.pathname).to.eq(
+ '/certification/developmentuser/responsive-web-design'
+ );
+ });
+ });
+ });
+});
diff --git a/cypress/support/commands.js b/cypress/support/commands.js
index 1f7113b743..42b9b23c2d 100644
--- a/cypress/support/commands.js
+++ b/cypress/support/commands.js
@@ -40,6 +40,22 @@ Cypress.Commands.add('login', () => {
cy.contains('Welcome back');
});
+Cypress.Commands.add('toggleAll', () => {
+ cy.login();
+ cy.visit('/settings');
+ // cy.get('input[name="isLocked"]').click();
+ // cy.get('input[name="name"]').click();
+ cy.get('#privacy-settings')
+ .find('.toggle-not-active')
+ .each(element => {
+ return new Cypress.Promise(resolve => {
+ cy.wrap(element).click().should('have.class', 'toggle-active');
+ resolve();
+ });
+ });
+ cy.get('#honesty-policy').find('button').click().wait(300);
+});
+
Cypress.Commands.add('resetUsername', () => {
cy.login();
cy.visit('/settings');