feat(client): improve SuperBlock cert claiming UX (#41147)

* feat(client): improve SuperBlock cert claiming UX

* broken: add certCard foundation

* broken: add TODO comments for scatter-brain

* restructure stepsToClaimSelector

* add api-server verifyCanClaimCert logic

* temp: correct verifyCanClaim URL

* move GET logic to CertificationCard, remove console.logs

* add error handling, and navigation logic

* correct verification logical flow

* fix completion-epic updates, fix cert verify

* update widget to button, disable button unless verified

* working: refactor CertChallenge with hook state

* add StepsType

* update Honesty snapshot

* add DonationModal to SuperBlockIntro

* disable Claim Cert button unless also isHonest

* prevent warning when viewing cert

* test: use navigate in Modal to return to hash

* test: replace gatsby.navigate with reach/router.navigate

* add propTypes

* fix: rename propTypes -> prop-types

* use react-scrollable-anchor to squash modal bug

* update location parser type

* open-source Oliver's suggestion

* fix superblock title

* add claim-cert-from-learn tests

* use larger tests

Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>

* fix some cypress stuff

* fix ShowCertification cypress test

Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
This commit is contained in:
Shaun Hamilton
2021-07-15 15:51:27 +01:00
committed by GitHub
parent 5a52c229f5
commit 6ca6d9950c
17 changed files with 634 additions and 253 deletions

View File

@ -19,7 +19,8 @@ import {
certTypeTitleMap, certTypeTitleMap,
certTypeIdMap, certTypeIdMap,
certIds, certIds,
oldDataVizId oldDataVizId,
superBlockCertTypeMap
} from '../../../../config/certification-settings'; } from '../../../../config/certification-settings';
const { const {
@ -49,9 +50,11 @@ export default function bootCertificate(app) {
const certTypeIds = createCertTypeIds(getChallenges()); const certTypeIds = createCertTypeIds(getChallenges());
const showCert = createShowCert(app); const showCert = createShowCert(app);
const verifyCert = createVerifyCert(certTypeIds, app); const verifyCert = createVerifyCert(certTypeIds, app);
const verifyCanClaimCert = createVerifyCanClaim(certTypeIds, app);
api.put('/certificate/verify', ifNoUser401, ifNoSuperBlock404, verifyCert); api.put('/certificate/verify', ifNoUser401, ifNoSuperBlock404, verifyCert);
api.get('/certificate/showCert/:username/:certSlug', showCert); api.get('/certificate/showCert/:username/:certSlug', showCert);
api.get('/certificate/verify-can-claim-cert', verifyCanClaimCert);
app.use(api); app.use(api);
} }
@ -494,3 +497,76 @@ function createShowCert(app) {
}, next); }, 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);
});
};
}

View File

@ -5582,6 +5582,15 @@
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.1.0.tgz", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.1.0.tgz",
"integrity": "sha512-1Yj8h9Q+QDF5FzhMs/c9+6UntbD5MkRfRwac8DoEm9ZfUBZ7tZ55YcGVAzEe4bXsdQHEk+s9S5wsOKVdZrw0tQ==" "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": { "bl": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
@ -9468,6 +9477,12 @@
"token-types": "^2.0.0" "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": { "filesize": {
"version": "6.1.0", "version": "6.1.0",
"resolved": "https://registry.npmjs.org/filesize/-/filesize-6.1.0.tgz", "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", "resolved": "https://registry.npmjs.org/name-all-modules-plugin/-/name-all-modules-plugin-1.0.1.tgz",
"integrity": "sha1-Cr+2rYNXGLn7Te8GdOBmV6lUN1w=" "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": { "nanoid": {
"version": "3.1.23", "version": "3.1.23",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.1.23.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.1.23.tgz",
@ -20766,7 +20787,11 @@
"version": "1.2.13", "version": "1.2.13",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.13.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.13.tgz",
"integrity": "sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==", "integrity": "sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==",
"optional": true "optional": true,
"requires": {
"bindings": "^1.5.0",
"nan": "^2.12.1"
}
}, },
"glob-parent": { "glob-parent": {
"version": "3.1.0", "version": "3.1.0",

View File

@ -136,6 +136,7 @@
"@types/loadable__component": "5.13.4", "@types/loadable__component": "5.13.4",
"@types/lodash-es": "4.17.4", "@types/lodash-es": "4.17.4",
"@types/prismjs": "1.16.6", "@types/prismjs": "1.16.6",
"@types/reach__router": "^1.3.8",
"@types/react-dom": "17.0.9", "@types/react-dom": "17.0.9",
"@types/react-helmet": "6.1.2", "@types/react-helmet": "6.1.2",
"@types/react-instantsearch-dom": "6.10.2", "@types/react-instantsearch-dom": "6.10.2",

View File

@ -11,6 +11,8 @@ import Cup from '../../assets/icons/cup';
import DonateForm from './DonateForm'; import DonateForm from './DonateForm';
import { modalDefaultDonation } from '../../../../config/donation-settings'; import { modalDefaultDonation } from '../../../../config/donation-settings';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { goToAnchor } from 'react-scrollable-anchor';
import { isLocationSuperBlock } from '../../utils/path-parsers';
import { import {
closeDonationModal, closeDonationModal,
@ -43,6 +45,10 @@ const propTypes = {
activeDonors: PropTypes.number, activeDonors: PropTypes.number,
closeDonationModal: PropTypes.func.isRequired, closeDonationModal: PropTypes.func.isRequired,
executeGA: PropTypes.func, executeGA: PropTypes.func,
location: PropTypes.shape({
hash: PropTypes.string,
pathname: PropTypes.string
}),
recentlyClaimedBlock: PropTypes.string, recentlyClaimedBlock: PropTypes.string,
show: PropTypes.bool show: PropTypes.bool
}; };
@ -51,6 +57,7 @@ function DonateModal({
show, show,
closeDonationModal, closeDonationModal,
executeGA, executeGA,
location,
recentlyClaimedBlock recentlyClaimedBlock
}) { }) {
const [closeLabel, setCloseLabel] = React.useState(false); 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 = ( const blockDonationText = (
<div className=' text-center block-modal-text'> <div className=' text-center block-modal-text'>
<div className='donation-icon-container'> <div className='donation-icon-container'>
@ -131,7 +145,12 @@ function DonateModal({
); );
return ( return (
<Modal bsSize='lg' className='donation-modal' show={show}> <Modal
bsSize='lg'
className='donation-modal'
onExited={handleModalHide}
show={show}
>
<Modal.Body> <Modal.Body>
{recentlyClaimedBlock ? blockDonationText : progressDonationText} {recentlyClaimedBlock ? blockDonationText : progressDonationText}
<Spacer /> <Spacer />

View File

@ -176,6 +176,19 @@ export const completedChallengesSelector = state =>
userSelector(state).completedChallenges || []; userSelector(state).completedChallenges || [];
export const completionCountSelector = state => state[ns].completionCount; export const completionCountSelector = state => state[ns].completionCount;
export const currentChallengeIdSelector = state => state[ns].currentChallengeId; 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 isDonatingSelector = state => userSelector(state).isDonating;
export const isOnlineSelector = state => state[ns].isOnline; export const isOnlineSelector = state => state[ns].isOnline;
export const isSignedInSelector = state => !!state[ns].appUsername; export const isSignedInSelector = state => !!state[ns].appUsername;

View File

@ -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 // TYPESCRIPT TYPES
export type CurrentCertType = { export type CurrentCertType = {

View File

@ -5,7 +5,7 @@ import {
catchError, catchError,
concat, concat,
filter, filter,
tap finalize
} from 'rxjs/operators'; } from 'rxjs/operators';
import { ofType } from 'redux-observable'; import { ofType } from 'redux-observable';
import { navigate } from 'gatsby'; import { navigate } from 'gatsby';
@ -24,11 +24,13 @@ import {
isSignedInSelector, isSignedInSelector,
submitComplete, submitComplete,
updateComplete, updateComplete,
updateFailed updateFailed,
usernameSelector
} from '../../../redux'; } from '../../../redux';
import postUpdate$ from '../utils/postUpdate$'; import postUpdate$ from '../utils/postUpdate$';
import { challengeTypes, submitTypes } from '../../../../utils/challengeTypes'; import { challengeTypes, submitTypes } from '../../../../utils/challengeTypes';
import { getVerifyCanClaimCert } from '../../../utils/ajax';
function postChallenge(update, username) { function postChallenge(update, username) {
const saveChallenge = postUpdate$(update).pipe( const saveChallenge = postUpdate$(update).pipe(
@ -133,7 +135,7 @@ export default function completionEpic(action$, state$) {
switchMap(({ type }) => { switchMap(({ type }) => {
const state = state$.value; const state = state$.value;
const meta = challengeMetaSelector(state); const meta = challengeMetaSelector(state);
const { nextChallengePath, challengeType } = meta; const { nextChallengePath, challengeType, superBlock } = meta;
const closeChallengeModal = of(closeModal('completion')); const closeChallengeModal = of(closeModal('completion'));
let submitter = () => of({ type: 'no-user-signed-in' }); let submitter = () => of({ type: 'no-user-signed-in' });
@ -150,11 +152,55 @@ export default function completionEpic(action$, state$) {
submitter = submitters[submitTypes[challengeType]]; submitter = submitters[submitTypes[challengeType]];
} }
const pathToNavigateTo = async () => {
return await findPathToNavigateTo(
nextChallengePath,
superBlock,
state,
challengeType
);
};
return submitter(type, state).pipe( return submitter(type, state).pipe(
tap(() => navigate(nextChallengePath)),
concat(closeChallengeModal), 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;
}

View File

@ -1,4 +1,4 @@
import React, { Component, Fragment } from 'react'; import React, { Fragment, useEffect, memo } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import Helmet from 'react-helmet'; import Helmet from 'react-helmet';
import { graphql } from 'gatsby'; import { graphql } from 'gatsby';
@ -15,11 +15,13 @@ import Map from '../../components/Map';
import CertChallenge from './components/CertChallenge'; import CertChallenge from './components/CertChallenge';
import SuperBlockIntro from './components/SuperBlockIntro'; import SuperBlockIntro from './components/SuperBlockIntro';
import Block from './components/Block'; import Block from './components/Block';
import DonateModal from '../../../../client/src/components/Donation/DonationModal';
import { Spacer } from '../../components/helpers'; import { Spacer } from '../../components/helpers';
import { import {
currentChallengeIdSelector, currentChallengeIdSelector,
userFetchStateSelector, userFetchStateSelector,
isSignedInSelector, isSignedInSelector,
tryToShowDonationModal,
userSelector userSelector
} from '../../redux'; } from '../../redux';
import { resetExpansion, toggleBlock } from './redux'; import { resetExpansion, toggleBlock } from './redux';
@ -42,6 +44,7 @@ const propTypes = {
isSignedIn: PropTypes.bool, isSignedIn: PropTypes.bool,
location: PropTypes.shape({ location: PropTypes.shape({
hash: PropTypes.string, hash: PropTypes.string,
// TODO: state is sometimes a string
state: PropTypes.shape({ state: PropTypes.shape({
breadcrumbBlockClick: PropTypes.string breadcrumbBlockClick: PropTypes.string
}) })
@ -49,6 +52,7 @@ const propTypes = {
resetExpansion: PropTypes.func, resetExpansion: PropTypes.func,
t: PropTypes.func, t: PropTypes.func,
toggleBlock: PropTypes.func, toggleBlock: PropTypes.func,
tryToShowDonationModal: PropTypes.func.isRequired,
user: User user: User
}; };
@ -71,24 +75,30 @@ const mapStateToProps = state => {
const mapDispatchToProps = dispatch => const mapDispatchToProps = dispatch =>
bindActionCreators( bindActionCreators(
{ resetExpansion, toggleBlock: b => toggleBlock(b) }, {
tryToShowDonationModal,
resetExpansion,
toggleBlock: b => toggleBlock(b)
},
dispatch dispatch
); );
class SuperBlockIntroductionPage extends Component { const SuperBlockIntroductionPage = props => {
componentDidMount() { useEffect(() => {
this.initializeExpandedState(); initializeExpandedState();
props.tryToShowDonationModal();
setTimeout(() => { setTimeout(() => {
configureAnchors({ offset: -40, scrollDuration: 400 }); configureAnchors({ offset: -40, scrollDuration: 400 });
}, 0); }, 0);
}
componentWillUnmount() { return () => {
configureAnchors({ offset: -40, scrollDuration: 0 }); configureAnchors({ offset: -40, scrollDuration: 0 });
} };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
getChosenBlock() { const getChosenBlock = () => {
const { const {
data: { data: {
allChallengeNode: { edges } allChallengeNode: { edges }
@ -96,7 +106,7 @@ class SuperBlockIntroductionPage extends Component {
isSignedIn, isSignedIn,
currentChallengeId, currentChallengeId,
location location
} = this.props; } = props;
// if coming from breadcrumb click // if coming from breadcrumb click
if (location.state && location.state.breadcrumbBlockClick) { if (location.state && location.state.breadcrumbBlockClick) {
@ -123,16 +133,15 @@ class SuperBlockIntroductionPage extends Component {
} }
return edge.node.block; return edge.node.block;
} };
initializeExpandedState() { const initializeExpandedState = () => {
const { resetExpansion, toggleBlock } = this.props; const { resetExpansion, toggleBlock } = props;
resetExpansion(); resetExpansion();
return toggleBlock(this.getChosenBlock()); return toggleBlock(getChosenBlock());
} };
render() {
const { const {
data: { data: {
markdownRemark: { markdownRemark: {
@ -143,7 +152,7 @@ class SuperBlockIntroductionPage extends Component {
isSignedIn, isSignedIn,
t, t,
user user
} = this.props; } = props;
const nodesForSuperBlock = edges.map(({ node }) => node); const nodesForSuperBlock = edges.map(({ node }) => node);
const blockDashedNames = uniq(nodesForSuperBlock.map(({ block }) => block)); const blockDashedNames = uniq(nodesForSuperBlock.map(({ block }) => block));
@ -186,7 +195,6 @@ class SuperBlockIntroductionPage extends Component {
{superBlock !== 'coding-interview-prep' && ( {superBlock !== 'coding-interview-prep' && (
<div> <div>
<CertChallenge <CertChallenge
isSignedIn={isSignedIn}
superBlock={superBlock} superBlock={superBlock}
title={title} title={title}
user={user} user={user}
@ -213,10 +221,10 @@ class SuperBlockIntroductionPage extends Component {
</Col> </Col>
</Row> </Row>
</Grid> </Grid>
<DonateModal location={props.location} />
</> </>
); );
} };
}
SuperBlockIntroductionPage.displayName = 'SuperBlockIntroductionPage'; SuperBlockIntroductionPage.displayName = 'SuperBlockIntroductionPage';
SuperBlockIntroductionPage.propTypes = propTypes; SuperBlockIntroductionPage.propTypes = propTypes;
@ -224,7 +232,7 @@ SuperBlockIntroductionPage.propTypes = propTypes;
export default connect( export default connect(
mapStateToProps, mapStateToProps,
mapDispatchToProps mapDispatchToProps
)(withTranslation()(SuperBlockIntroductionPage)); )(withTranslation()(memo(SuperBlockIntroductionPage)));
export const query = graphql` export const query = graphql`
query SuperBlockIntroPageBySlug($slug: String!, $superBlock: String!) { query SuperBlockIntroPageBySlug($slug: String!, $superBlock: String!) {

View File

@ -1,97 +1,154 @@
import React, { Component } from 'react'; import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { navigate } from 'gatsby';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import { withTranslation } from 'react-i18next'; 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 { certMap } from '../../../resources/cert-and-project-map';
import { import {
certSlugTypeMap, certSlugTypeMap,
superBlockCertTypeMap superBlockCertTypeMap
} from '../../../../../config/certification-settings'; } from '../../../../../config/certification-settings';
import CertificationCard from './CertificationCard'; import { getVerifyCanClaimCert } from '../../../utils/ajax';
import { navigate } from 'gatsby-link';
const propTypes = { const propTypes = {
currentCerts: CurrentCertsType, createFlashMessage: PropTypes.func.isRequired,
isSignedIn: PropTypes.bool.isRequired, steps: StepsType,
superBlock: PropTypes.string, superBlock: PropTypes.string,
t: PropTypes.func, t: PropTypes.func,
title: PropTypes.string, title: PropTypes.string,
user: User user: User,
verifyCert: PropTypes.func.isRequired
}; };
const mapStateToProps = (state, props) => { const honestyInfoMessage = {
return createSelector( type: 'info',
certificatesByNameSelector(props.user.username), message: 'flash.honest-first'
({ currentCerts }) => ({
currentCerts
})
)(state, props);
}; };
export class CertChallenge extends Component { const mapStateToProps = state => {
render() { return createSelector(stepsToClaimSelector, steps => ({
const { steps
isSignedIn, }))(state);
};
const mapDispatchToProps = {
createFlashMessage,
verifyCert
};
const CertChallenge = ({
createFlashMessage,
steps = {},
superBlock, superBlock,
t, t,
verifyCert,
title, title,
user: { username }, user: { isHonest, username }
currentCerts }) => {
} = this.props; 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);
const cert = certMap.find(x => x.title === title); useEffect(() => {
const isCertified = currentCerts.find( 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 => cert =>
certSlugTypeMap[cert.certSlug] === superBlockCertTypeMap[superBlock] certSlugTypeMap[cert.certSlug] === superBlockCertTypeMap[superBlock]
).show; )?.show ?? false
const certLocation = `/certification/${username}/${cert.certSlug}`; );
const certCheckmarkStyle = { height: '40px', width: '40px' };
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 i18nSuperBlock = t(`intro:${superBlock}.title`);
const i18nCertText = t(`intro:misc-text.certification`, { const i18nCertText = t(`intro:misc-text.certification`, {
cert: i18nSuperBlock cert: i18nSuperBlock
}); });
const { certSlug } = cert;
const createClickHandler = certSlug => e => {
e.preventDefault();
if (isCertified) {
return navigate(certLocation);
}
return isHonest
? verifyCert(certSlug)
: createFlashMessage(honestyInfoMessage);
};
return ( return (
<div className='block'> <div className='block'>
{isSignedIn && !isCertified && ( {(!isCertified || !canViewCert) && (
<CertificationCard <CertificationCard
certSlug={certSlug}
i18nCertText={i18nCertText} i18nCertText={i18nCertText}
isProjectsCompleted={isProjectsCompleted}
steps={steps}
stepState={stepState}
superBlock={superBlock} superBlock={superBlock}
/> />
)} )}
<button <Button
className={`map-cert-title ${ block={true}
isCertified ? 'map-is-cert' : 'no-cursor' bsStyle='primary'
}`} disabled={!canClaim.status || (isCertified && !canViewCert)}
onClick={isCertified ? () => navigate(certLocation) : null} href={certLocation}
onClick={createClickHandler(certSlug)}
> >
<CertificationIcon /> {isCertified ? t('buttons.show-cert') : t('buttons.claim-cert')}
<h3>{i18nCertText}</h3> </Button>
<div className='map-title-completed-big'>
<span>
{isCertified ? (
<GreenPass style={certCheckmarkStyle} />
) : (
<GreenNotCompleted style={certCheckmarkStyle} />
)}
</span>
</div>
</button>
</div> </div>
); );
} };
}
CertChallenge.displayName = 'CertChallenge'; CertChallenge.displayName = 'CertChallenge';
CertChallenge.propTypes = propTypes; CertChallenge.propTypes = propTypes;
export default connect(mapStateToProps)(withTranslation()(CertChallenge)); export { CertChallenge };
export default connect(
mapStateToProps,
mapDispatchToProps
)(withTranslation()(CertChallenge));

View File

@ -1,18 +1,35 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import PropTypes from 'prop-types'; 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 ClaimCertSteps from './ClaimCertSteps';
import GreenPass from '../../../assets/icons/green-pass';
import { StepsType } from '../../../redux/prop-types';
import Caret from '../../../assets/icons/caret'; import Caret from '../../../assets/icons/caret';
const propTypes = { const propTypes = {
certSlug: PropTypes.string,
i18nCertText: PropTypes.string, i18nCertText: PropTypes.string,
isProjectsCompleted: PropTypes.bool,
stepState: PropTypes.shape({
numberOfSteps: PropTypes.number,
completedCount: PropTypes.number
}),
steps: StepsType,
superBlock: PropTypes.string 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 { t } = useTranslation();
const [isExpanded, setIsExpanded] = useState(true); const [isExpanded, setIsExpanded] = useState(true);
@ -23,7 +40,7 @@ const CertificationCard = ({ certSlug, superBlock, i18nCertText }) => {
const { const {
expand: expandText, expand: expandText,
collapse: collapseText, collapse: collapseText,
steps: stepsText courses: coursesText
} = t('intro:misc-text'); } = t('intro:misc-text');
return ( return (
<ScrollableAnchor id='claim-cert-block'> <ScrollableAnchor id='claim-cert-block'>
@ -48,13 +65,22 @@ const CertificationCard = ({ certSlug, superBlock, i18nCertText }) => {
<h4 className='course-title'> <h4 className='course-title'>
{`${ {`${
isExpanded ? collapseText : expandText isExpanded ? collapseText : expandText
} ${stepsText.toLowerCase()}`} } ${coursesText.toLowerCase()}`}
</h4> </h4>
<div className='map-title-completed course-title'>
{completedCount === numberOfSteps ? (
<GreenPass style={mapIconStyle} />
) : (
<GreenNotCompleted style={mapIconStyle} />
)}
<span className='map-completed-count'>{`${completedCount}/${numberOfSteps}`}</span>
</div>
</button> </button>
{isExpanded && ( {isExpanded && (
<ClaimCertSteps <ClaimCertSteps
certSlug={certSlug}
i18nCertText={i18nCertText} i18nCertText={i18nCertText}
isProjectsCompleted={isProjectsCompleted}
steps={steps}
superBlock={superBlock} superBlock={superBlock}
/> />
)} )}

View File

@ -2,16 +2,27 @@ import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { Link } from 'gatsby'; import { Link } from 'gatsby';
import { withTranslation, useTranslation } from 'react-i18next'; 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 GreenPass from '../../../assets/icons/green-pass';
import GreenNotCompleted from '../../../assets/icons/green-not-completed'; 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 mapIconStyle = { height: '15px', marginRight: '10px', width: '15px' };
const propTypes = {
i18nCertText: PropTypes.string,
isProjectsCompleted: PropTypes.bool,
steps: StepsType,
superBlock: PropTypes.string
};
const ClaimCertSteps = ({
isProjectsCompleted,
i18nCertText,
steps,
superBlock
}) => {
const { t } = useTranslation();
const renderCheckMark = isCompleted => { const renderCheckMark = isCompleted => {
return isCompleted ? ( return isCompleted ? (
<GreenPass style={mapIconStyle} /> <GreenPass style={mapIconStyle} />
@ -20,82 +31,52 @@ const renderCheckMark = isCompleted => {
); );
}; };
const propTypes = {
certSlug: PropTypes.string,
i18nCertText: PropTypes.string,
superBlock: PropTypes.string,
user: User
};
const mapStateToProps = state => {
return createSelector(userSelector, user => ({
user
}))(state);
};
const ClaimCertSteps = ({ certSlug, i18nCertText, superBlock, user }) => {
const { t } = useTranslation();
const settingsLink = '/settings#privacy-settings'; const settingsLink = '/settings#privacy-settings';
const certClaimLink = `/settings#cert-${certSlug}`;
const honestyPolicyAnchor = '/settings#honesty-policy'; const honestyPolicyAnchor = '/settings#honesty-policy';
const { const {
name, isHonest = false,
isHonest, isShowName = false,
profileUI: { isLocked, showCerts, showName } isShowCerts = false,
} = user; isShowProfile = false
} = steps;
return ( return (
<ul className='map-challenges-ul'> <ul className='map-challenges-ul' data-cy='claim-cert-steps'>
<li className='map-challenge-title map-project-wrap'> <li className='map-challenge-title map-challenge-wrap'>
<Link to={honestyPolicyAnchor}> <Link to={honestyPolicyAnchor}>
<span className='badge map-badge'>{renderCheckMark(isHonest)}</span>
{t('certification-card.accept-honesty')} {t('certification-card.accept-honesty')}
<span className='badge map-badge map-project-checkmark'>
{renderCheckMark(isHonest)}
</span>
</Link> </Link>
</li> </li>
<li className='map-challenge-title map-project-wrap'> <li className='map-challenge-title map-challenge-wrap'>
<Link to={settingsLink}>
{t('certification-card.set-profile-public')}
<span className='badge map-badge map-project-checkmark'>
{renderCheckMark(!isLocked)}
</span>
</Link>
</li>
<li className='map-challenge-title map-project-wrap'>
<Link to={settingsLink}>
{t('certification-card.set-certs-public')}
<span className='badge map-badge map-project-checkmark'>
{renderCheckMark(showCerts)}
</span>
</Link>
</li>
<li className='map-challenge-title map-project-wrap'>
<Link to={settingsLink}>
{t('certification-card.set-name')}
<span className='badge map-badge map-project-checkmark'>
{renderCheckMark(name && name !== '' && showName)}
</span>
</Link>
</li>
<li className='map-challenge-title map-project-wrap'>
<a href={`#${superBlock}-projects`}> <a href={`#${superBlock}-projects`}>
<span className='badge map-badge'>
{renderCheckMark(isProjectsCompleted)}
</span>
{t('certification-card.complete-project', { {t('certification-card.complete-project', {
i18nCertText i18nCertText
})} })}
<span className='badge map-badge map-project-checkmark'>
<IntroInformation style={mapIconStyle} />
</span>
</a> </a>
</li> </li>
<li className='map-challenge-title map-project-wrap'> <li className='map-challenge-title map-challenge-wrap'>
<Link to={certClaimLink}> <Link to={settingsLink}>
{t('certification-card.set-claim')} <span className='badge map-badge'>
<span className='badge map-badge map-project-checkmark'> {renderCheckMark(isShowProfile)}
<IntroInformation style={mapIconStyle} />
</span> </span>
{t('certification-card.set-profile-public')}
</Link>
</li>
<li className='map-challenge-title map-challenge-wrap'>
<Link to={settingsLink}>
<span className='badge map-badge'>
{renderCheckMark(isShowCerts)}
</span>
{t('certification-card.set-certs-public')}
</Link>
</li>
<li className='map-challenge-title map-challenge-wrap'>
<Link to={settingsLink}>
<span className='badge map-badge'>{renderCheckMark(isShowName)}</span>
{t('certification-card.set-name')}
</Link> </Link>
</li> </li>
</ul> </ul>
@ -105,4 +86,4 @@ const ClaimCertSteps = ({ certSlug, i18nCertText, superBlock, user }) => {
ClaimCertSteps.displayName = 'ClaimCertSteps'; ClaimCertSteps.displayName = 'ClaimCertSteps';
ClaimCertSteps.propTypes = propTypes; ClaimCertSteps.propTypes = propTypes;
export default connect(mapStateToProps)(withTranslation()(ClaimCertSteps)); export default withTranslation()(ClaimCertSteps);

View File

@ -82,6 +82,16 @@ export function getUsernameExists(username: string): Promise<boolean> {
return get(`/api/users/exists?username=${username}`); return get(`/api/users/exists?username=${username}`);
} }
// TODO: Does a GET return a bolean?
export function getVerifyCanClaimCert(
username: string,
superBlock: string
): Promise<boolean> {
return get(
`/certificate/verify-can-claim-cert?username=${username}&superBlock=${superBlock}`
);
}
/** POST **/ /** POST **/
interface Donation { interface Donation {

View File

@ -1,3 +1,4 @@
import { WindowLocation } from '@reach/router';
import { i18nConstants } from '../../../config/constants'; import { i18nConstants } from '../../../config/constants';
const splitPath = (pathname: string): string[] => const splitPath = (pathname: string): string[] =>
@ -19,5 +20,11 @@ export const isLanding = (pathname: string): boolean => {
return isEnglishLanding || isI18Landing; return isEnglishLanding || isI18Landing;
}; };
export const isLocationSuperBlock = (
location: WindowLocation | undefined
): boolean => {
return /^\/learn\/[\w-]+\/$/.test(location?.pathname ?? '');
};
const pathParsers = { isLanding, isChallenge }; const pathParsers = { isLanding, isChallenge };
export default pathParsers; export default pathParsers;

View File

@ -45,7 +45,7 @@ describe('A certification,', function () {
.click(); .click();
cy.contains('Submit and go to next challenge').click().wait(1000); 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'); cy.visit('/settings');
// set user settings to public to claim a cert // set user settings to public to claim a cert

View File

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

View File

@ -40,6 +40,22 @@ Cypress.Commands.add('login', () => {
cy.contains('Welcome back'); 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', () => { Cypress.Commands.add('resetUsername', () => {
cy.login(); cy.login();
cy.visit('/settings'); cy.visit('/settings');