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');