Skip to content

Commit

Permalink
feat(client): improve SuperBlock cert claiming UX (#41147)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
ShaunSHamilton and ojeytonwilliams committed Jul 15, 2021
1 parent 5a52c22 commit 6ca6d99
Show file tree
Hide file tree
Showing 17 changed files with 632 additions and 251 deletions.
78 changes: 77 additions & 1 deletion api-server/src/server/boot/certificate.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ import {
certTypeTitleMap,
certTypeIdMap,
certIds,
oldDataVizId
oldDataVizId,
superBlockCertTypeMap
} from '../../../../config/certification-settings';

const {
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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);
});
};
}
2 changes: 1 addition & 1 deletion client/i18n/locales/english/translations.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 changes: 26 additions & 1 deletion client/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -162,4 +163,4 @@
"webpack": "5.44.0",
"webpack-cli": "4.7.2"
}
}
}
21 changes: 20 additions & 1 deletion client/src/components/Donation/DonationModal.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
};
Expand All @@ -51,6 +57,7 @@ function DonateModal({
show,
closeDonationModal,
executeGA,
location,
recentlyClaimedBlock
}) {
const [closeLabel, setCloseLabel] = React.useState(false);
Expand Down Expand Up @@ -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'>
Expand Down Expand Up @@ -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 />
Expand Down
13 changes: 13 additions & 0 deletions client/src/redux/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
7 changes: 7 additions & 0 deletions client/src/redux/prop-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
56 changes: 51 additions & 5 deletions client/src/templates/Challenges/redux/completion-epic.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
catchError,
concat,
filter,
tap
finalize
} from 'rxjs/operators';
import { ofType } from 'redux-observable';
import { navigate } from 'gatsby';
Expand All @@ -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(
Expand Down Expand Up @@ -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' });
Expand All @@ -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;
}

0 comments on commit 6ca6d99

Please sign in to comment.