From 5539dbf086a69390170824cf79be0b6e4a326c12 Mon Sep 17 00:00:00 2001 From: Shaun Hamilton <51722130+ShaunSHamilton@users.noreply.github.com> Date: Mon, 1 Feb 2021 13:25:14 +0000 Subject: [PATCH] feat(client): add project links to certificate (#40071) Co-authored-by: Oliver Eyton-Williams --- .../client-only-routes/ShowCertification.js | 66 +++++- .../client-only-routes/ShowProjectLinks.js | 200 ++++++++++++++++++ .../components/SolutionViewer/ProjectModal.js | 59 ++++++ .../SolutionViewer.js | 0 .../src/components/layouts/project-links.css | 16 ++ .../components/profile/components/TimeLine.js | 2 +- .../src/components/settings/Certification.js | 35 +-- 7 files changed, 345 insertions(+), 33 deletions(-) create mode 100644 client/src/client-only-routes/ShowProjectLinks.js create mode 100644 client/src/components/SolutionViewer/ProjectModal.js rename client/src/components/{settings => SolutionViewer}/SolutionViewer.js (100%) create mode 100644 client/src/components/layouts/project-links.css diff --git a/client/src/client-only-routes/ShowCertification.js b/client/src/client-only-routes/ShowCertification.js index b76147b162..505c713319 100644 --- a/client/src/client-only-routes/ShowCertification.js +++ b/client/src/client-only-routes/ShowCertification.js @@ -6,6 +6,8 @@ import { connect } from 'react-redux'; import { createSelector } from 'reselect'; import format from 'date-fns/format'; import { Grid, Row, Col, Image, Button } from '@freecodecamp/react-bootstrap'; + +import ShowProjectLinks from './ShowProjectLinks'; import FreeCodeCampLogo from '../assets/icons/FreeCodeCampLogo'; // eslint-disable-next-line max-len import DonateForm from '../components/Donation/DonateForm'; @@ -18,7 +20,9 @@ import { userFetchStateSelector, usernameSelector, isDonatingSelector, - executeGA + executeGA, + userByNameSelector, + fetchProfileForUser } from '../redux'; import { certMap } from '../../src/resources/certAndProjectMap'; import { createFlashMessage } from '../components/Flash/redux'; @@ -27,6 +31,7 @@ import reallyWeirdErrorMessage from '../utils/reallyWeirdErrorMessage'; import RedirectHome from '../components/RedirectHome'; import { Loader, Spacer } from '../components/helpers'; +import { isEmpty } from 'lodash'; const propTypes = { cert: PropTypes.shape({ @@ -41,6 +46,7 @@ const propTypes = { certName: PropTypes.string, createFlashMessage: PropTypes.func.isRequired, executeGA: PropTypes.func, + fetchProfileForUser: PropTypes.func, fetchState: PropTypes.shape({ pending: PropTypes.bool, complete: PropTypes.bool, @@ -52,6 +58,25 @@ const propTypes = { }), showCert: PropTypes.func.isRequired, signedInUserName: PropTypes.string, + user: PropTypes.shape({ + completedChallenges: PropTypes.arrayOf( + PropTypes.shape({ + id: PropTypes.string, + solution: PropTypes.string, + githubLink: PropTypes.string, + files: PropTypes.arrayOf( + PropTypes.shape({ + contents: PropTypes.string, + ext: PropTypes.string, + key: PropTypes.string, + name: PropTypes.string, + path: PropTypes.string + }) + ) + }) + ), + username: PropTypes.string + }), userFetchState: PropTypes.shape({ complete: PropTypes.bool }), @@ -60,29 +85,37 @@ const propTypes = { validCertName: PropTypes.bool }; +const requestedUserSelector = (state, { username = '' }) => + userByNameSelector(username.toLowerCase())(state); + const validCertNames = certMap.map(cert => cert.slug); -const mapStateToProps = (state, { certName }) => { - const validCertName = validCertNames.some(name => name === certName); +const mapStateToProps = (state, props) => { + const validCertName = validCertNames.some(name => name === props.certName); return createSelector( showCertSelector, showCertFetchStateSelector, usernameSelector, userFetchStateSelector, isDonatingSelector, - (cert, fetchState, signedInUserName, userFetchState, isDonating) => ({ + requestedUserSelector, + (cert, fetchState, signedInUserName, userFetchState, isDonating, user) => ({ cert, fetchState, validCertName, signedInUserName, userFetchState, - isDonating + isDonating, + user }) ); }; const mapDispatchToProps = dispatch => - bindActionCreators({ createFlashMessage, showCert, executeGA }, dispatch); + bindActionCreators( + { createFlashMessage, showCert, fetchProfileForUser, executeGA }, + dispatch + ); const ShowCertification = props => { const { t } = useTranslation(); @@ -104,9 +137,17 @@ const ShowCertification = props => { signedInUserName, isDonating, cert: { username = '' }, + fetchProfileForUser, + user, executeGA } = props; + if (!signedInUserName || signedInUserName !== username) { + if (isEmpty(user) && username) { + fetchProfileForUser(username); + } + } + if ( !isDonationDisplayed && userComplete && @@ -125,7 +166,15 @@ const ShowCertification = props => { } }); } - }, [isDonationDisplayed, props]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + isDonationDisplayed, + props.userFetchState, + props.signedInUserName, + props.isDonating, + props.cert, + props.executeGA + ]); const hideDonationSection = () => { setIsDonationDisplayed(false); @@ -186,6 +235,7 @@ const ShowCertification = props => { certTitle, completionTime } = cert; + const { user } = props; const certDate = new Date(date); const certYear = certDate.getFullYear(); @@ -311,6 +361,8 @@ const ShowCertification = props => { {signedInUserName === username ? shareCertBtns : ''} + + ); }; diff --git a/client/src/client-only-routes/ShowProjectLinks.js b/client/src/client-only-routes/ShowProjectLinks.js new file mode 100644 index 0000000000..d3b6f037fd --- /dev/null +++ b/client/src/client-only-routes/ShowProjectLinks.js @@ -0,0 +1,200 @@ +/* eslint-disable react/jsx-sort-props */ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import '../components/layouts/project-links.css'; +import { maybeUrlRE } from '../utils'; +import { Spacer, Link } from '../components/helpers'; +import { projectMap, legacyProjectMap } from '../resources/certAndProjectMap'; +import ProjectModal from '../components/SolutionViewer/ProjectModal'; +import { find, first } from 'lodash'; + +const propTypes = { + certName: PropTypes.string, + name: PropTypes.string, + user: PropTypes.shape({ + completedChallenges: PropTypes.arrayOf( + PropTypes.shape({ + id: PropTypes.string, + solution: PropTypes.string, + githubLink: PropTypes.string, + files: PropTypes.arrayOf( + PropTypes.shape({ + contents: PropTypes.string, + ext: PropTypes.string, + key: PropTypes.string, + name: PropTypes.string, + path: PropTypes.string + }) + ) + }) + ), + username: PropTypes.string + }) +}; + +const initSolutionState = { + projectTitle: '', + files: null, + solution: null, + isOpen: false +}; + +const ShowProjectLinks = props => { + const [solutionState, setSolutionState] = useState(initSolutionState); + + const handleSolutionModalHide = () => setSolutionState(initSolutionState); + + const getProjectSolution = (projectId, projectTitle) => { + const { + user: { completedChallenges } + } = props; + + const completedProject = find( + completedChallenges, + ({ id }) => projectId === id + ); + + if (!completedProject) { + return null; + } + + const { solution, githubLink, files } = completedProject; + const onClickHandler = () => + setSolutionState({ + projectTitle, + files, + solution, + isOpen: true + }); + + if (files && files.length) { + return ( + + ); + } + if (githubLink) { + return ( + <> + + solution + + ,{' '} + + source + + + ); + } + if (maybeUrlRE.test(solution)) { + return ( + + solution + + ); + } + return ( + + ); + }; + + const renderProjectsFor = certName => { + if (certName === 'Legacy Full Stack') { + const legacyCerts = [ + { title: 'Responsive Web Design' }, + { title: 'JavaScript Algorithms and Data Structures' }, + { title: 'Front End Libraries' }, + { title: 'Data Visualization' }, + { title: 'APIs and Microservices' }, + { title: 'Legacy Information Security and Quality Assurance' } + ]; + return legacyCerts.map((cert, ind) => { + const mapToUse = projectMap[cert.title] || legacyProjectMap[cert.title]; + const { superBlock } = first(mapToUse); + const certLocation = `/certification/${username}/${superBlock}`; + return ( +
  • + + {cert.title} + +
  • + ); + }); + } + return (projectMap[certName] || legacyProjectMap[certName]).map( + ({ link, title, id }) => ( +
  • + + {title} + + : {getProjectSolution(id, title)} +
  • + ) + ); + }; + + const { + certName, + name, + user: { username } + } = props; + const { files, isOpen, projectTitle, solution } = solutionState; + return ( +
    + {certName === 'Legacy Full Stack' + ? `As part of this Legacy Full Stack certification, ${name} completed the following certifications:` + : `As part of this certification, ${name} built the following projects and got all automated test suites to pass:`} + +
      {renderProjectsFor(certName)}
    + + {isOpen ? ( + + ) : null} + If you suspect that any of these projects violate the{' '} + + academic honesty policy + + , please{' '} + + report this to our team + + . +
    + ); +}; + +ShowProjectLinks.propTypes = propTypes; +ShowProjectLinks.displayName = 'ShowProjectLinks'; + +export default ShowProjectLinks; diff --git a/client/src/components/SolutionViewer/ProjectModal.js b/client/src/components/SolutionViewer/ProjectModal.js new file mode 100644 index 0000000000..7f616e02ae --- /dev/null +++ b/client/src/components/SolutionViewer/ProjectModal.js @@ -0,0 +1,59 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import SolutionViewer from './SolutionViewer'; +import { Button, Modal } from '@freecodecamp/react-bootstrap'; + +const propTypes = { + files: PropTypes.arrayOf( + PropTypes.shape({ + contents: PropTypes.string, + ext: PropTypes.string, + key: PropTypes.string, + name: PropTypes.string, + path: PropTypes.string + }) + ), + handleSolutionModalHide: PropTypes.func, + isOpen: PropTypes.bool, + projectTitle: PropTypes.string, + solution: PropTypes.string, + t: PropTypes.func.isRequired +}; + +const ProjectModal = props => { + const { + isOpen, + projectTitle, + files, + solution, + t, + handleSolutionModalHide + } = props; + return ( + + + + {t('settings.labels.solution-for', { + projectTitle: projectTitle + })} + + + + + + + + + + ); +}; + +ProjectModal.propTypes = propTypes; +ProjectModal.displayName = 'ProjectModal'; + +export default ProjectModal; diff --git a/client/src/components/settings/SolutionViewer.js b/client/src/components/SolutionViewer/SolutionViewer.js similarity index 100% rename from client/src/components/settings/SolutionViewer.js rename to client/src/components/SolutionViewer/SolutionViewer.js diff --git a/client/src/components/layouts/project-links.css b/client/src/components/layouts/project-links.css new file mode 100644 index 0000000000..b7852e43d3 --- /dev/null +++ b/client/src/components/layouts/project-links.css @@ -0,0 +1,16 @@ +.project-link-button-override { + all: unset; + color: inherit; + cursor: pointer; + text-decoration: underline; +} + +.project-link-button-override:hover { + text-decoration: none; + color: var(--tertiary-color); + background-color: var(--tertiary-background); +} + +.project-link { + font-weight: 800; +} diff --git a/client/src/components/profile/components/TimeLine.js b/client/src/components/profile/components/TimeLine.js index f992cedcfb..4287fe9c2d 100644 --- a/client/src/components/profile/components/TimeLine.js +++ b/client/src/components/profile/components/TimeLine.js @@ -14,7 +14,7 @@ import { withTranslation } from 'react-i18next'; import './timeline.css'; import TimelinePagination from './TimelinePagination'; import { FullWidthRow, Link } from '../../helpers'; -import SolutionViewer from '../../settings/SolutionViewer'; +import SolutionViewer from '../../SolutionViewer/SolutionViewer'; import { getCertIds, getPathFromID, diff --git a/client/src/components/settings/Certification.js b/client/src/components/settings/Certification.js index c724d565c0..5a8973febb 100644 --- a/client/src/components/settings/Certification.js +++ b/client/src/components/settings/Certification.js @@ -7,8 +7,7 @@ import { Table, Button, DropdownButton, - MenuItem, - Modal + MenuItem } from '@freecodecamp/react-bootstrap'; import { Link, navigate } from 'gatsby'; import { createSelector } from 'reselect'; @@ -20,7 +19,7 @@ import { } from '../../resources/certAndProjectMap'; import SectionHeader from './SectionHeader'; -import SolutionViewer from './SolutionViewer'; +import ProjectModal from '../SolutionViewer/ProjectModal'; import { FullWidthRow, Spacer } from '../helpers'; import { Form } from '../formHelpers'; @@ -599,28 +598,14 @@ export class CertificationSettings extends Component { {this.renderLegacyFullStack()} {legacyCertifications.map(this.renderLegacyCertifications)} {isOpen ? ( - - - - {t('settings.labels.solution-for', { - projectTitle: projectTitle - })} - - - - - - - - - + ) : null} );