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:
@ -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);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
27
client/package-lock.json
generated
27
client/package-lock.json
generated
@ -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",
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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 />
|
||||
|
@ -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;
|
||||
|
@ -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 = {
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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!) {
|
||||
|
@ -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));
|
||||
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
@ -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);
|
||||
|
@ -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 {
|
||||
|
@ -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;
|
||||
|
@ -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
|
||||
|
@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@ -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');
|
||||
|
Reference in New Issue
Block a user