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,
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);
});
};
}

View File

@ -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",

View File

@ -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",

View File

@ -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 = (
<div className=' text-center block-modal-text'>
<div className='donation-icon-container'>
@ -131,7 +145,12 @@ function DonateModal({
);
return (
<Modal bsSize='lg' className='donation-modal' show={show}>
<Modal
bsSize='lg'
className='donation-modal'
onExited={handleModalHide}
show={show}
>
<Modal.Body>
{recentlyClaimedBlock ? blockDonationText : progressDonationText}
<Spacer />

View File

@ -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;

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
export type CurrentCertType = {

View File

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

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 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 (
<>
<Helmet>
<title>{i18nTitle} | freeCodeCamp.org</title>
</Helmet>
<Grid>
<Row className='super-block-intro-page'>
<Col md={8} mdOffset={2} sm={10} smOffset={1} xs={12}>
<Spacer size={2} />
<SuperBlockIntro superBlock={superBlock} />
<Spacer size={2} />
<h2 className='text-center big-subheading'>
{t(`intro:misc-text.courses`)}
</h2>
<Spacer />
<div className='block-ui'>
{blockDashedNames.map(blockDashedName => (
<Fragment key={blockDashedName}>
<Block
blockDashedName={blockDashedName}
challenges={nodesForSuperBlock.filter(
node => node.block === blockDashedName
)}
superBlock={superBlock}
/>
{blockDashedName !== 'project-euler' ? <Spacer /> : null}
</Fragment>
))}
{superBlock !== 'coding-interview-prep' && (
<div>
<CertChallenge
isSignedIn={isSignedIn}
superBlock={superBlock}
title={title}
user={user}
/>
</div>
)}
</div>
{!isSignedIn && (
return (
<>
<Helmet>
<title>{i18nTitle} | freeCodeCamp.org</title>
</Helmet>
<Grid>
<Row className='super-block-intro-page'>
<Col md={8} mdOffset={2} sm={10} smOffset={1} xs={12}>
<Spacer size={2} />
<SuperBlockIntro superBlock={superBlock} />
<Spacer size={2} />
<h2 className='text-center big-subheading'>
{t(`intro:misc-text.courses`)}
</h2>
<Spacer />
<div className='block-ui'>
{blockDashedNames.map(blockDashedName => (
<Fragment key={blockDashedName}>
<Block
blockDashedName={blockDashedName}
challenges={nodesForSuperBlock.filter(
node => node.block === blockDashedName
)}
superBlock={superBlock}
/>
{blockDashedName !== 'project-euler' ? <Spacer /> : null}
</Fragment>
))}
{superBlock !== 'coding-interview-prep' && (
<div>
<Spacer size={2} />
<Login block={true}>{t('buttons.logged-out-cta-btn')}</Login>
<CertChallenge
superBlock={superBlock}
title={title}
user={user}
/>
</div>
)}
<Spacer size={2} />
<h3
className='text-center big-block-title'
style={{ whiteSpace: 'pre-line' }}
>
{t(`intro:misc-text.browse-other`)}
</h3>
<Spacer />
<Map currentSuperBlock={superBlock} />
<Spacer size={2} />
</Col>
</Row>
</Grid>
</>
);
}
}
</div>
{!isSignedIn && (
<div>
<Spacer size={2} />
<Login block={true}>{t('buttons.logged-out-cta-btn')}</Login>
</div>
)}
<Spacer size={2} />
<h3
className='text-center big-block-title'
style={{ whiteSpace: 'pre-line' }}
>
{t(`intro:misc-text.browse-other`)}
</h3>
<Spacer />
<Map currentSuperBlock={superBlock} />
<Spacer size={2} />
</Col>
</Row>
</Grid>
<DonateModal location={props.location} />
</>
);
};
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!) {

View File

@ -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 (
<div className='block'>
{isSignedIn && !isCertified && (
<CertificationCard
certSlug={certSlug}
i18nCertText={i18nCertText}
superBlock={superBlock}
/>
)}
<button
className={`map-cert-title ${
isCertified ? 'map-is-cert' : 'no-cursor'
}`}
onClick={isCertified ? () => navigate(certLocation) : null}
>
<CertificationIcon />
<h3>{i18nCertText}</h3>
<div className='map-title-completed-big'>
<span>
{isCertified ? (
<GreenPass style={certCheckmarkStyle} />
) : (
<GreenNotCompleted style={certCheckmarkStyle} />
)}
</span>
</div>
</button>
</div>
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 (
<div className='block'>
{(!isCertified || !canViewCert) && (
<CertificationCard
i18nCertText={i18nCertText}
isProjectsCompleted={isProjectsCompleted}
steps={steps}
stepState={stepState}
superBlock={superBlock}
/>
)}
<Button
block={true}
bsStyle='primary'
disabled={!canClaim.status || (isCertified && !canViewCert)}
href={certLocation}
onClick={createClickHandler(certSlug)}
>
{isCertified ? t('buttons.show-cert') : t('buttons.claim-cert')}
</Button>
</div>
);
};
CertChallenge.displayName = 'CertChallenge';
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 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 (
<ScrollableAnchor id='claim-cert-block'>
@ -48,13 +65,22 @@ const CertificationCard = ({ certSlug, superBlock, i18nCertText }) => {
<h4 className='course-title'>
{`${
isExpanded ? collapseText : expandText
} ${stepsText.toLowerCase()}`}
} ${coursesText.toLowerCase()}`}
</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>
{isExpanded && (
<ClaimCertSteps
certSlug={certSlug}
i18nCertText={i18nCertText}
isProjectsCompleted={isProjectsCompleted}
steps={steps}
superBlock={superBlock}
/>
)}

View File

@ -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 ? (
<GreenPass style={mapIconStyle} />
) : (
<GreenNotCompleted style={mapIconStyle} />
);
};
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 ? (
<GreenPass style={mapIconStyle} />
) : (
<GreenNotCompleted style={mapIconStyle} />
);
};
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 (
<ul className='map-challenges-ul'>
<li className='map-challenge-title map-project-wrap'>
<ul className='map-challenges-ul' data-cy='claim-cert-steps'>
<li className='map-challenge-title map-challenge-wrap'>
<Link to={honestyPolicyAnchor}>
<span className='badge map-badge'>{renderCheckMark(isHonest)}</span>
{t('certification-card.accept-honesty')}
<span className='badge map-badge map-project-checkmark'>
{renderCheckMark(isHonest)}
</span>
</Link>
</li>
<li className='map-challenge-title map-project-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'>
<li className='map-challenge-title map-challenge-wrap'>
<a href={`#${superBlock}-projects`}>
<span className='badge map-badge'>
{renderCheckMark(isProjectsCompleted)}
</span>
{t('certification-card.complete-project', {
i18nCertText
})}
<span className='badge map-badge map-project-checkmark'>
<IntroInformation style={mapIconStyle} />
</span>
</a>
</li>
<li className='map-challenge-title map-project-wrap'>
<Link to={certClaimLink}>
{t('certification-card.set-claim')}
<span className='badge map-badge map-project-checkmark'>
<IntroInformation style={mapIconStyle} />
<li className='map-challenge-title map-challenge-wrap'>
<Link to={settingsLink}>
<span className='badge map-badge'>
{renderCheckMark(isShowProfile)}
</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>
</li>
</ul>
@ -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);

View File

@ -82,6 +82,16 @@ export function getUsernameExists(username: string): Promise<boolean> {
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 **/
interface Donation {

View File

@ -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;

View File

@ -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

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