diff --git a/api-server/src/server/boot/certificate.js b/api-server/src/server/boot/certificate.js index dd9240dcad..9f38a1189b 100644 --- a/api-server/src/server/boot/certificate.js +++ b/api-server/src/server/boot/certificate.js @@ -12,15 +12,13 @@ import { certTypeTitleMap, certTypeIdMap, certIds, - oldDataVizId, - superBlockCertTypeMap + oldDataVizId } from '../../../../config/certification-settings'; import { reportError } from '../middlewares/sentry-error-handler.js'; import { getChallenges } from '../utils/get-curriculum'; import { ifNoUser401 } from '../utils/middleware'; import { observeQuery } from '../utils/rx'; -import { ensureLowerCaseString } from '../../common/models/user'; const { legacyFrontEndChallengeId, @@ -50,7 +48,6 @@ 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); @@ -59,6 +56,15 @@ export default function bootCertificate(app) { app.use(api); } +function verifyCanClaimCert(_req, res) { + return res.status(410).json({ + message: { + type: 'info', + message: 'Please reload the app, this feature is no longer available.' + } + }); +} + export function getFallbackFullStackDate(completedChallenges, completedDate) { var chalIds = [ certTypeIdMap[certTypes.respWebDesign], @@ -508,97 +514,3 @@ 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$(ensureLowerCaseString(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 => { - if (!user) { - return res.status(404).json({ - message: { - type: 'info', - message: 'flash.username-not-found', - variables: { username } - } - }); - } - - if (!certTypeIds[certType]) { - return res.status(404).json({ - message: { - type: 'info', - // TODO: create a specific 'flash.cert-not-found' message - message: 'flash.could-not-find' - } - }); - } - - return Observable.of(certTypeIds[certType]) - .flatMap(challenge => { - const certName = certTypeTitleMap[certType]; - const { tests = [] } = challenge; - let result = 'incomplete-requirements'; - let status = false; - - const { isHonest, completedChallenges } = user; - const isProjectsCompleted = canClaim(tests, completedChallenges); - - 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); - }); - }; -} diff --git a/client/i18n/locales/english/translations.json b/client/i18n/locales/english/translations.json index c3b157311a..1892ef5b77 100644 --- a/client/i18n/locales/english/translations.json +++ b/client/i18n/locales/english/translations.json @@ -63,7 +63,8 @@ "submit-and-go": "Submit and go to next challenge", "go-to-next": "Go to next challenge", "ask-later": "Ask me later", - "start-coding": "Start coding!" + "start-coding": "Start coding!", + "go-to-settings": "Go to settings to claim your certification" }, "landing": { "big-heading-1": "Learn to code — for free.", diff --git a/client/src/redux/index.js b/client/src/redux/index.js index c3a199268e..8d9d36d77c 100644 --- a/client/src/redux/index.js +++ b/client/src/redux/index.js @@ -189,19 +189,7 @@ export const completedChallengesSelector = state => export const completionCountSelector = state => state[MainApp].completionCount; export const currentChallengeIdSelector = state => state[MainApp].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 emailSelector = state => userSelector(state).email; export const isAVariantSelector = state => { const email = emailSelector(state); @@ -271,6 +259,9 @@ export const userByNameSelector = username => state => { return user[username] ?? initialState.user; }; +export const currentCertsSelector = state => + certificatesByNameSelector(state[MainApp]?.appUsername)(state)?.currentCerts; + export const certificatesByNameSelector = username => state => { const { isRespWebDesignCert, diff --git a/client/src/templates/Challenges/redux/completion-epic.js b/client/src/templates/Challenges/redux/completion-epic.js index 71207811c7..383e62a799 100644 --- a/client/src/templates/Challenges/redux/completion-epic.js +++ b/client/src/templates/Challenges/redux/completion-epic.js @@ -17,11 +17,9 @@ import { isSignedInSelector, submitComplete, updateComplete, - updateFailed, - usernameSelector + updateFailed } from '../../../redux'; -import { getVerifyCanClaimCert } from '../../../utils/ajax'; import postUpdate$ from '../utils/postUpdate$'; import { actionTypes } from './action-types'; import { @@ -151,8 +149,7 @@ export default function completionEpic(action$, state$) { switchMap(({ type }) => { const state = state$.value; const meta = challengeMetaSelector(state); - const { nextChallengePath, challengeType, superBlock, certification } = - meta; + const { nextChallengePath, challengeType, superBlock } = meta; const closeChallengeModal = of(closeModal('completion')); let submitter = () => of({ type: 'no-user-signed-in' }); @@ -170,13 +167,7 @@ export default function completionEpic(action$, state$) { } const pathToNavigateTo = async () => { - return await findPathToNavigateTo( - certification, - nextChallengePath, - superBlock, - state, - challengeType - ); + return await findPathToNavigateTo(nextChallengePath, superBlock); }; return submitter(type, state).pipe( @@ -188,38 +179,10 @@ export default function completionEpic(action$, state$) { ); } -async function findPathToNavigateTo( - certification, - 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, certification); - 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`; +async function findPathToNavigateTo(nextChallengePath, superBlock) { + if (nextChallengePath.includes(superBlock)) { + return nextChallengePath; } else { - pathToNavigateTo = `/learn/${superBlock}/#${superBlock}-projects`; + return `/learn/${superBlock}/#${superBlock}-projects`; } - return pathToNavigateTo; } diff --git a/client/src/templates/Introduction/components/cert-challenge.tsx b/client/src/templates/Introduction/components/cert-challenge.tsx index ad10d13807..97ed8582f4 100644 --- a/client/src/templates/Introduction/components/cert-challenge.tsx +++ b/client/src/templates/Introduction/components/cert-challenge.tsx @@ -1,6 +1,6 @@ import { Button } from '@freecodecamp/react-bootstrap'; import { navigate } from 'gatsby-link'; -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, MouseEvent } from 'react'; import { TFunction, withTranslation } from 'react-i18next'; import { connect } from 'react-redux'; import { createSelector } from 'reselect'; @@ -13,14 +13,12 @@ import { createFlashMessage } from '../../../components/Flash/redux'; import { FlashMessages } from '../../../components/Flash/redux/flash-messages'; import { userFetchStateSelector, - stepsToClaimSelector, - isSignedInSelector + isSignedInSelector, + currentCertsSelector } from '../../../redux'; import { User, Steps } from '../../../redux/prop-types'; import { verifyCert } from '../../../redux/settings'; import { certMap } from '../../../resources/cert-and-project-map'; -import { getVerifyCanClaimCert } from '../../../utils/ajax'; -import CertificationCard from './certification-card'; interface CertChallengeProps { // TODO: create enum/reuse SuperBlocks enum somehow @@ -33,7 +31,7 @@ interface CertChallengeProps { error: null | string; }; isSignedIn: boolean; - steps: Steps; + currentCerts: Steps['currentCerts']; superBlock: SuperBlocks; t: TFunction; title: typeof certMap[number]['title']; @@ -48,11 +46,16 @@ const honestyInfoMessage = { const mapStateToProps = (state: unknown) => { return createSelector( - stepsToClaimSelector, + currentCertsSelector, userFetchStateSelector, isSignedInSelector, - (steps, fetchState: CertChallengeProps['fetchState'], isSignedIn) => ({ - steps, + ( + currentCerts, + fetchState: CertChallengeProps['fetchState'], + isSignedIn + ) => ({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + currentCerts, fetchState, isSignedIn }) @@ -65,9 +68,8 @@ const mapDispatchToProps = { }; const CertChallenge = ({ - certification, createFlashMessage, - steps = {}, + currentCerts, superBlock, t, verifyCert, @@ -76,45 +78,8 @@ const CertChallenge = ({ isSignedIn, user: { isHonest, username } }: CertChallengeProps): JSX.Element => { - const [canClaimCert, setCanClaimCert] = useState(false); - const [certVerificationMessage, setCertVerificationMessage] = useState(''); const [isCertified, setIsCertified] = useState(false); const [userLoaded, setUserLoaded] = useState(false); - const [verificationComplete, setVerificationComplete] = useState(false); - const [stepState, setStepState] = useState({ - numberOfSteps: 0, - completedCount: 0 - }); - const [hasCompletedRequiredSteps, setHasCompletedRequiredSteps] = - useState(false); - const [isProjectsCompleted, setIsProjectsCompleted] = useState(false); - - useEffect(() => { - if (username) { - void (async () => { - try { - const data = await getVerifyCanClaimCert(username, certification); - if (data?.message) { - setCanClaimCert(false); - createFlashMessage(data.message); - } else { - const { status, result } = data?.response?.message; - setCanClaimCert(status); - setCertVerificationMessage(result); - } - } catch (e) { - console.error(e); - createFlashMessage({ - type: 'danger', - message: FlashMessages.ReallyWeird - }); - } finally { - setVerificationComplete(true); - } - })(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [username]); // @ts-expect-error Typescript is confused const certSlug = certMap.find(x => x.title === title).certSlug; @@ -133,40 +98,19 @@ const CertChallenge = ({ useEffect(() => { setIsCertified( - steps?.currentCerts?.find( + currentCerts?.find( (cert: { certSlug: string }) => certSlugTypeMapTyped[cert.certSlug] === superBlockCertTypeMapTyped[superBlock] )?.show ?? false ); - - const projectsCompleted = - canClaimCert || certVerificationMessage === 'projects-completed'; - const projectsCompletedNumber = projectsCompleted ? 1 : 0; - const completedCount = - Object.values(steps).filter( - stepVal => typeof stepVal === 'boolean' && stepVal - ).length + projectsCompletedNumber; - const numberOfSteps = Object.keys(steps).length; - setHasCompletedRequiredSteps(completedCount === numberOfSteps); - setStepState({ numberOfSteps, completedCount }); - setIsProjectsCompleted(projectsCompleted); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [steps, canClaimCert, certVerificationMessage]); + }, [currentCerts]); const certLocation = `/certification/${username}/${certSlug}`; - const i18nSuperBlock = t(`intro:${superBlock}.title`); - const i18nCertText = t(`intro:misc-text.certification`, { - cert: i18nSuperBlock - }); - - const showCertificationCard = - userLoaded && - isSignedIn && - (!isCertified || (!hasCompletedRequiredSteps && verificationComplete)); const createClickHandler = - (certSlug: string | undefined) => (e: { preventDefault: () => void }) => { + (certSlug: string | undefined) => (e: MouseEvent) => { e.preventDefault(); if (isCertified) { return navigate(certLocation); @@ -177,33 +121,19 @@ const CertChallenge = ({ }; return (
- {showCertificationCard && ( - + {isSignedIn && ( + )} - <> - {isSignedIn && ( - - )} -
); }; diff --git a/client/src/templates/Introduction/components/certification-card.tsx b/client/src/templates/Introduction/components/certification-card.tsx deleted file mode 100644 index 807d1d3c03..0000000000 --- a/client/src/templates/Introduction/components/certification-card.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import React, { useState } from 'react'; - -import { useTranslation } from 'react-i18next'; -import ScrollableAnchor from 'react-scrollable-anchor'; -import { SuperBlocks } from '../../../../../config/certification-settings'; -import Caret from '../../../assets/icons/caret'; -import GreenNotCompleted from '../../../assets/icons/green-not-completed'; -import GreenPass from '../../../assets/icons/green-pass'; -import { Steps } from '../../../redux/prop-types'; -import ClaimCertSteps from './claim-cert-steps'; - -interface CertificationCardProps { - i18nCertText: string; - isProjectsCompleted: boolean; - stepState: { - numberOfSteps: number; - completedCount: number; - }; - steps: Steps; - superBlock: SuperBlocks; -} -const mapIconStyle = { height: '15px', marginRight: '10px', width: '15px' }; - -const CertificationCard = ({ - isProjectsCompleted, - superBlock, - i18nCertText, - stepState: { completedCount, numberOfSteps }, - steps -}: CertificationCardProps): JSX.Element => { - const { t } = useTranslation(); - const [isExpanded, setIsExpanded] = useState(true); - - const handleBlockClick = () => { - setIsExpanded(!isExpanded); - }; - - const { - expand: expandText, - collapse: collapseText - }: { - expand: string; - collapse: string; - } = t('intro:misc-text'); - return ( - -
-
- -

- {t('certification-card.title')} - # -

-
-
-
- {t('certification-card.intro', { i18nCertText })} -
- - {isExpanded && ( - - )} -
-
- ); -}; - -CertificationCard.displayName = 'CertStatus'; - -export default CertificationCard; diff --git a/client/src/templates/Introduction/components/claim-cert-steps.tsx b/client/src/templates/Introduction/components/claim-cert-steps.tsx deleted file mode 100644 index 620b932610..0000000000 --- a/client/src/templates/Introduction/components/claim-cert-steps.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import { Link } from 'gatsby'; -import React from 'react'; -import { withTranslation, useTranslation } from 'react-i18next'; -import { SuperBlocks } from '../../../../../config/certification-settings'; -import GreenNotCompleted from '../../../assets/icons/green-not-completed'; -import GreenPass from '../../../assets/icons/green-pass'; -import { Steps } from '../../../redux/prop-types'; - -const mapIconStyle = { height: '15px', marginRight: '10px', width: '15px' }; - -interface ClaimCertStepsProps { - i18nCertText: string; - isProjectsCompleted: boolean; - steps: Steps; - superBlock: SuperBlocks; -} - -const ClaimCertSteps = ({ - isProjectsCompleted, - i18nCertText, - steps, - superBlock -}: ClaimCertStepsProps): JSX.Element => { - const { t } = useTranslation(); - const renderCheckMark = (isCompleted: boolean) => { - return isCompleted ? ( - - ) : ( - - ); - }; - - const settingsLink = '/settings#privacy-settings'; - const honestyPolicyAnchor = '/settings#honesty-policy'; - const { - isHonest = false, - isShowName = false, - isShowCerts = false, - isShowProfile = false - } = steps; - - return ( - - ); -}; - -ClaimCertSteps.displayName = 'ClaimCertSteps'; - -export default withTranslation()(ClaimCertSteps); diff --git a/client/src/utils/ajax.ts b/client/src/utils/ajax.ts index 5af27525b0..da722ff3a4 100644 --- a/client/src/utils/ajax.ts +++ b/client/src/utils/ajax.ts @@ -1,10 +1,8 @@ import cookies from 'browser-cookies'; import envData from '../../../config/env.json'; -import { FlashMessageArg } from '../components/Flash/redux'; import type { ChallengeFile, - ClaimedCertifications, CompletedChallenge, User } from '../redux/prop-types'; @@ -25,6 +23,8 @@ function getCSRFToken() { return token ?? ''; } +// TODO: Might want to handle flash messages as close to the request as possible +// to make use of the Response object (message, status, etc) async function get(path: string): Promise { return fetch(`${base}${path}`, defaultOptions).then(res => res.json()); } @@ -160,31 +160,6 @@ export function getUsernameExists(username: string): Promise { return get(`/api/users/exists?username=${username}`); } -export interface GetVerifyCanClaimCert { - response: { - type: string; - message: { - status: boolean; - result: string; - }; - variables: { - name: string; - }; - }; - isCertMap: ClaimedCertifications; - completedChallenges: CompletedChallenge[]; - message?: FlashMessageArg; -} - -export function getVerifyCanClaimCert( - username: string, - certification: string -): Promise { - return get( - `/certificate/verify-can-claim-cert?username=${username}&superBlock=${certification}` - ); -} - /** POST **/ interface Donation { diff --git a/cypress/integration/learn/challenges/projects.js b/cypress/integration/learn/challenges/projects.js index a5a65d516d..9a358f7722 100644 --- a/cypress/integration/learn/challenges/projects.js +++ b/cypress/integration/learn/challenges/projects.js @@ -78,7 +78,8 @@ describe('project submission', () => { // We need to wait for everything to finish loading and hydrating, so we // use this text as a proxy for that. const textInNextPage = projectTitles.slice(1); - textInNextPage.push('Claim Your Certification'); + // The following text exists on the donation modal + textInNextPage.push('Nicely done'); projectsInOrder.forEach( ({ block, superBlock, dashedName, solutions }, i) => { @@ -118,8 +119,10 @@ describe('project submission', () => { // Claim and view solutions on certification page cy.toggleAll(); - cy.visit('/learn/javascript-algorithms-and-data-structures'); - cy.contains('Claim Certification').click(); + cy.visit('/settings'); + cy.get( + `a[href="/certification/developmentuser/${projectsInOrder[0]?.superBlock}"]` + ).click(); cy.contains('Show Certification').click(); projectTitles.forEach(title => { diff --git a/cypress/integration/learn/responsive-web-design/claim-cert-from-learn.js b/cypress/integration/learn/responsive-web-design/show-cert-from-superblock.js similarity index 59% rename from cypress/integration/learn/responsive-web-design/claim-cert-from-learn.js rename to cypress/integration/learn/responsive-web-design/show-cert-from-superblock.js index 49aaecfc5a..10e2d6349f 100644 --- a/cypress/integration/learn/responsive-web-design/claim-cert-from-learn.js +++ b/cypress/integration/learn/responsive-web-design/show-cert-from-superblock.js @@ -34,19 +34,9 @@ describe('Responsive Web Design Superblock', () => { 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); + it('should navigate to "/settings#certification-settings" when clicking the "Go to settings to claim your certification" anchor', () => { + cy.contains('Go to settings to claim your certification').click(); + cy.url().should('include', '/settings#certification-settings'); }); }); describe('After submitting all 5 projects', () => { @@ -64,7 +54,7 @@ describe('Responsive Web Design Superblock', () => { cy.contains("I've completed this challenge") .should('not.be.disabled') .click(); - cy.intercept('http://localhost:3000/project-completed').as( + cy.intercept(`${Cypress.env('API_LOCATION')}/project-completed`).as( 'challengeCompleted' ); cy.contains('Submit and go to next challenge').click(); @@ -74,26 +64,21 @@ describe('Responsive Web Design Superblock', () => { cy.location().should(loc => { expect(loc.pathname).to.not.eq(url); }); + cy.visit('/settings'); + cy.get( + `[href="/certification/developmentuser/${projects.superBlock}"]` + ).click(); + cy.visit(`/learn/${projects.superBlock}/`); }); }); - it('should be possible to claim and view certifications from the superBlock page', () => { + it('should be possible to 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.exist'); - // directed to claim-cert-block section - cy.url().should('include', '#claim-cert-block'); - // make sure that the window has not snapped to the top (a weird bug that - // we never figured out and so could randomly reappear) - cy.window().its('scrollY').should('not.equal', 0); - cy.contains('Claim Your Certification'); - 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' + `/certification/developmentuser/${projects.superBlock}` ); }); }); diff --git a/cypress/plugins/index.js b/cypress/plugins/index.js index fbce33037e..69e8ecefa8 100644 --- a/cypress/plugins/index.js +++ b/cypress/plugins/index.js @@ -28,5 +28,6 @@ module.exports = (on, config) => { // Allows us to test the new curriculum before it's released: config.env.SHOW_UPCOMING_CHANGES = process.env.SHOW_UPCOMING_CHANGES; + config.env.API_LOCATION = process.env.API_LOCATION; return config; }; diff --git a/cypress/support/commands.js b/cypress/support/commands.js index c85d078178..3110f954bf 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -33,7 +33,7 @@ // Cypress.Commands.overwrite('visit', (originalFn, url, options) => {}); Cypress.Commands.add('login', () => { - cy.visit('http://localhost:3000/signin'); + cy.visit(`${Cypress.env('API_LOCATION')}/signin`); cy.contains('Welcome back'); });