feat(client): add project links to certificate (#40071)
Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
This commit is contained in:
@ -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 => {
|
||||
</Row>
|
||||
</Grid>
|
||||
{signedInUserName === username ? shareCertBtns : ''}
|
||||
<Spacer size={2} />
|
||||
<ShowProjectLinks user={user} name={userFullName} certName={certTitle} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
200
client/src/client-only-routes/ShowProjectLinks.js
Normal file
200
client/src/client-only-routes/ShowProjectLinks.js
Normal file
@ -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 (
|
||||
<button
|
||||
onClick={onClickHandler}
|
||||
className='project-link-button-override'
|
||||
>
|
||||
solution
|
||||
</button>
|
||||
);
|
||||
}
|
||||
if (githubLink) {
|
||||
return (
|
||||
<>
|
||||
<a href={solution} rel='noopener noreferrer' target='_blank'>
|
||||
solution
|
||||
</a>
|
||||
,{' '}
|
||||
<a href={githubLink} rel='noopener noreferrer' target='_blank'>
|
||||
source
|
||||
</a>
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (maybeUrlRE.test(solution)) {
|
||||
return (
|
||||
<a
|
||||
block={'true'}
|
||||
className='btn-invert'
|
||||
href={solution}
|
||||
rel='noopener noreferrer'
|
||||
target='_blank'
|
||||
>
|
||||
solution
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<button className='project-link-button-override' onClick={onClickHandler}>
|
||||
solution
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<li key={ind}>
|
||||
<a
|
||||
href={certLocation}
|
||||
className='btn-invert project-link'
|
||||
rel='noopener noreferrer'
|
||||
target='_blank'
|
||||
>
|
||||
{cert.title}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
});
|
||||
}
|
||||
return (projectMap[certName] || legacyProjectMap[certName]).map(
|
||||
({ link, title, id }) => (
|
||||
<li key={id}>
|
||||
<Link to={link} className='project-link'>
|
||||
{title}
|
||||
</Link>
|
||||
: {getProjectSolution(id, title)}
|
||||
</li>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const {
|
||||
certName,
|
||||
name,
|
||||
user: { username }
|
||||
} = props;
|
||||
const { files, isOpen, projectTitle, solution } = solutionState;
|
||||
return (
|
||||
<div>
|
||||
{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:`}
|
||||
<Spacer />
|
||||
<ul>{renderProjectsFor(certName)}</ul>
|
||||
<Spacer />
|
||||
{isOpen ? (
|
||||
<ProjectModal
|
||||
files={files}
|
||||
handleSolutionModalHide={handleSolutionModalHide}
|
||||
isOpen={isOpen}
|
||||
projectTitle={projectTitle}
|
||||
solution={solution}
|
||||
/>
|
||||
) : null}
|
||||
If you suspect that any of these projects violate the{' '}
|
||||
<a
|
||||
href='https://www.freecodecamp.org/news/academic-honesty-policy/'
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
academic honesty policy
|
||||
</a>
|
||||
, please{' '}
|
||||
<a
|
||||
href={`/user/${username}/report-user`}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
report this to our team
|
||||
</a>
|
||||
.
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ShowProjectLinks.propTypes = propTypes;
|
||||
ShowProjectLinks.displayName = 'ShowProjectLinks';
|
||||
|
||||
export default ShowProjectLinks;
|
59
client/src/components/SolutionViewer/ProjectModal.js
Normal file
59
client/src/components/SolutionViewer/ProjectModal.js
Normal file
@ -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 (
|
||||
<Modal
|
||||
aria-labelledby='solution-viewer-modal-title'
|
||||
bsSize='large'
|
||||
onHide={handleSolutionModalHide}
|
||||
show={isOpen}
|
||||
>
|
||||
<Modal.Header className='this-one?' closeButton={true}>
|
||||
<Modal.Title id='solution-viewer-modal-title'>
|
||||
{t('settings.labels.solution-for', {
|
||||
projectTitle: projectTitle
|
||||
})}
|
||||
</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<SolutionViewer files={files} solution={solution} />
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button onClick={handleSolutionModalHide}>{t('buttons.close')}</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
ProjectModal.propTypes = propTypes;
|
||||
ProjectModal.displayName = 'ProjectModal';
|
||||
|
||||
export default ProjectModal;
|
16
client/src/components/layouts/project-links.css
Normal file
16
client/src/components/layouts/project-links.css
Normal file
@ -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;
|
||||
}
|
@ -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,
|
||||
|
@ -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 ? (
|
||||
<Modal
|
||||
aria-labelledby='solution-viewer-modal-title'
|
||||
bsSize='large'
|
||||
onHide={this.handleSolutionModalHide}
|
||||
show={isOpen}
|
||||
>
|
||||
<Modal.Header className='this-one?' closeButton={true}>
|
||||
<Modal.Title id='solution-viewer-modal-title'>
|
||||
{t('settings.labels.solution-for', {
|
||||
projectTitle: projectTitle
|
||||
})}
|
||||
</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<SolutionViewer files={files} solution={solution} />
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button onClick={this.handleSolutionModalHide}>
|
||||
{t('buttons.close')}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
<ProjectModal
|
||||
files={files}
|
||||
handleSolutionModalHide={this.handleSolutionModalHide}
|
||||
isOpen={isOpen}
|
||||
projectTitle={projectTitle}
|
||||
solution={solution}
|
||||
t={t}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
|
Reference in New Issue
Block a user