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,
|
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);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
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",
|
"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",
|
||||||
|
@ -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",
|
||||||
|
@ -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 />
|
||||||
|
@ -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;
|
||||||
|
@ -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 = {
|
||||||
|
@ -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;
|
||||||
|
}
|
||||||
|
@ -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!) {
|
||||||
|
@ -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));
|
||||||
|
@ -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}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
@ -2,100 +2,81 @@ 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 renderCheckMark = isCompleted => {
|
|
||||||
|
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 => {
|
||||||
return isCompleted ? (
|
return isCompleted ? (
|
||||||
<GreenPass style={mapIconStyle} />
|
<GreenPass style={mapIconStyle} />
|
||||||
) : (
|
) : (
|
||||||
<GreenNotCompleted style={mapIconStyle} />
|
<GreenNotCompleted style={mapIconStyle} />
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
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);
|
||||||
|
@ -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 {
|
||||||
|
@ -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;
|
||||||
|
@ -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
|
||||||
|
@ -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');
|
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');
|
||||||
|
Reference in New Issue
Block a user