From 987da192541cb49b07e76c5ee886f743e0aa7330 Mon Sep 17 00:00:00 2001 From: Bouncey Date: Wed, 7 Nov 2018 18:15:27 +0000 Subject: [PATCH] feat(profile): Add Profile and components --- client/package-lock.json | 5 + client/package.json | 1 + .../ShowProfileOrFourOhFour.js | 98 ++++++++++ .../{pages => components/FourOhFour}/404.css | 0 client/src/components/FourOhFour/index.js | 64 +++++++ .../helpers/CurrentChallengeLink.js | 40 ++++ client/src/components/profile/Profile.js | 176 ++++++++++++++++++ .../components/profile/components/Camper.js | 113 +++++++++++ .../profile/components/Certifications.js | 165 ++++++++++++++++ .../components/profile/components/HeatMap.js | 60 ++++++ .../profile/components/Portfolio.js | 59 ++++++ .../profile/components/SocialIcons.js | 90 +++++++++ .../components/profile/components/TimeLine.js | 170 +++++++++++++++++ .../components/profile/components/camper.css | 8 + .../components/profile/components/heatmap.css | 15 ++ .../profile/components/social-icons.css | 7 + client/src/pages/404.js | 73 ++------ client/src/redux/fetch-user-saga.js | 32 +++- client/src/redux/index.js | 55 +++++- client/src/redux/rootSaga.js | 3 +- .../Challenges/redux/id-to-name-map-saga.js | 22 +++ .../src/templates/Challenges/redux/index.js | 44 +++-- client/src/utils/ajax.js | 8 + client/static/css/cal-heatmap.css | 133 +++++++++++++ 24 files changed, 1358 insertions(+), 83 deletions(-) create mode 100644 client/src/client-only-routes/ShowProfileOrFourOhFour.js rename client/src/{pages => components/FourOhFour}/404.css (100%) create mode 100644 client/src/components/FourOhFour/index.js create mode 100644 client/src/components/helpers/CurrentChallengeLink.js create mode 100644 client/src/components/profile/Profile.js create mode 100644 client/src/components/profile/components/Camper.js create mode 100644 client/src/components/profile/components/Certifications.js create mode 100644 client/src/components/profile/components/HeatMap.js create mode 100644 client/src/components/profile/components/Portfolio.js create mode 100644 client/src/components/profile/components/SocialIcons.js create mode 100644 client/src/components/profile/components/TimeLine.js create mode 100644 client/src/components/profile/components/camper.css create mode 100644 client/src/components/profile/components/heatmap.css create mode 100644 client/src/components/profile/components/social-icons.css create mode 100644 client/src/templates/Challenges/redux/id-to-name-map-saga.js create mode 100644 client/static/css/cal-heatmap.css diff --git a/client/package-lock.json b/client/package-lock.json index 5b31992619..21487535cb 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -4428,6 +4428,11 @@ } } }, + "date-fns": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-1.29.0.tgz", + "integrity": "sha512-lbTXWZ6M20cWH8N9S6afb0SBm6tMk+uUg6z3MqHPKE9atmsY3kJkTm8vKe93izJ2B2+q5MV990sM2CHgtAZaOw==" + }, "date-now": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/date-now/-/date-now-0.1.4.tgz", diff --git a/client/package.json b/client/package.json index 562a836d3d..80703370d1 100644 --- a/client/package.json +++ b/client/package.json @@ -19,6 +19,7 @@ "axios": "^0.18.0", "browser-cookies": "^1.2.0", "chai": "^4.2.0", + "date-fns": "^1.29.0", "enzyme": "^3.6.0", "enzyme-adapter-react-16": "^1.5.0", "fetchr": "^0.5.37", diff --git a/client/src/client-only-routes/ShowProfileOrFourOhFour.js b/client/src/client-only-routes/ShowProfileOrFourOhFour.js new file mode 100644 index 0000000000..1ff86350d6 --- /dev/null +++ b/client/src/client-only-routes/ShowProfileOrFourOhFour.js @@ -0,0 +1,98 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { bindActionCreators } from 'redux'; +import { connect } from 'react-redux'; +import { isEmpty } from 'lodash'; + +import Loader from '../components/helpers/Loader'; +import Layout from '../components/layouts/Default'; +import { + userByNameSelector, + userProfileFetchStateSelector, + fetchProfileForUser, + usernameSelector +} from '../redux'; +import FourOhFourPage from '../components/FourOhFour'; +import Profile from '../components/profile/Profile'; + +const propTypes = { + fetchProfileForUser: PropTypes.func.isRequired, + isSessionUser: PropTypes.bool, + maybeUser: PropTypes.string, + requestedUser: PropTypes.shape({ + username: PropTypes.string, + profileUI: PropTypes.object + }), + showLoading: PropTypes.bool, + splat: PropTypes.string +}; + +const createRequestedUserSelector = () => (state, { maybeUser }) => + userByNameSelector(maybeUser)(state); +const createIsSessionUserSelector = () => (state, { maybeUser }) => + maybeUser === usernameSelector(state); + +const makeMapStateToProps = () => (state, props) => { + const requestedUserSelector = createRequestedUserSelector(); + const isSessionUserSelector = createIsSessionUserSelector(); + const fetchState = userProfileFetchStateSelector(state, props); + return { + requestedUser: requestedUserSelector(state, props), + isSessionUser: isSessionUserSelector(state, props), + showLoading: fetchState.pending, + fetchState + }; +}; + +const mapDispatchToProps = dispatch => + bindActionCreators({ fetchProfileForUser }, dispatch); + +class ShowFourOhFour extends Component { + componentDidMount() { + const { requestedUser, maybeUser, splat, fetchProfileForUser } = this.props; + if (!splat && isEmpty(requestedUser)) { + console.log(requestedUser); + return fetchProfileForUser(maybeUser); + } + return null; + } + + render() { + const { isSessionUser, requestedUser, showLoading, splat } = this.props; + if (splat) { + // the uri path for this component is /:maybeUser/:splat + // if splat is defined then we on a route that is not a profile + // and we should just 404 + return ; + } + if (showLoading) { + // We don't know if /:maybeUser is a user or not, we will show the loader + // until we get a response from the API + return ( + +
+ +
+
+ ); + } + if (isEmpty(requestedUser)) { + // We have a response from the API, but there is nothing in the store + // for /:maybeUser. We can derive from this state the /:maybeUser is not + // a user the API recognises, so we 404 + return ; + } + + // We have a response from the API, and we have some state in the + // store for /:maybeUser, we now handover rendering to the Profile component + return ; + } +} + +ShowFourOhFour.displayName = 'ShowFourOhFour'; +ShowFourOhFour.propTypes = propTypes; + +export default connect( + makeMapStateToProps, + mapDispatchToProps +)(ShowFourOhFour); diff --git a/client/src/pages/404.css b/client/src/components/FourOhFour/404.css similarity index 100% rename from client/src/pages/404.css rename to client/src/components/FourOhFour/404.css diff --git a/client/src/components/FourOhFour/index.js b/client/src/components/FourOhFour/index.js new file mode 100644 index 0000000000..1864f96669 --- /dev/null +++ b/client/src/components/FourOhFour/index.js @@ -0,0 +1,64 @@ +import React, { Component } from 'react'; +import Helmet from 'react-helmet'; +import Spinner from 'react-spinkit'; +import { Link } from 'gatsby'; + +import Layout from '../layouts/Default'; + +import notFoundLogo from '../../images/freeCodeCamp-404.svg'; +import { quotes } from '../../resources/quotes.json'; + +import './404.css'; + +class NotFoundPage extends Component { + constructor(props) { + super(props); + this.state = { + randomQuote: null + }; + } + + componentDidMount() { + this.updateQuote(); + } + + updateQuote() { + this.setState({ + randomQuote: quotes[Math.floor(Math.random() * quotes.length)] + }); + } + + render() { + return ( + +
+ + 404 Not Found +

NOT FOUND

+ {this.state.randomQuote ? ( +
+

+ We couldn't find what you were looking for, but here is a + quote: +

+
+

+ + {this.state.randomQuote.quote} +

+

- {this.state.randomQuote.author}

+
+
+ ) : ( + + )} + + View the Curriculum + +
+
+ ); + } +} + +export default NotFoundPage; diff --git a/client/src/components/helpers/CurrentChallengeLink.js b/client/src/components/helpers/CurrentChallengeLink.js new file mode 100644 index 0000000000..d71b378c02 --- /dev/null +++ b/client/src/components/helpers/CurrentChallengeLink.js @@ -0,0 +1,40 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { bindActionCreators } from 'redux'; +import { connect } from 'react-redux'; + +import { apiLocation } from '../../../config/env.json'; + +import { hardGoTo } from '../../redux'; + +const currentChallengeApi = '/challenges/current-challenge'; + +const propTypes = { + children: PropTypes.any, + hardGoTo: PropTypes.func.isRequired +}; + +const mapStateToProps = () => ({}); +const mapDispatchToProps = dispatch => + bindActionCreators({ hardGoTo }, dispatch); + +const createClickHandler = hardGoTo => e => { + e.preventDefault(); + return hardGoTo(`${apiLocation}${currentChallengeApi}`); +}; + +function CurrentChallengeLink({ children, hardGoTo }) { + return ( + + {children} + + ); +} + +CurrentChallengeLink.displayName = 'CurrentChallengeLink'; +CurrentChallengeLink.propTypes = propTypes; + +export default connect( + mapStateToProps, + mapDispatchToProps +)(CurrentChallengeLink); diff --git a/client/src/components/profile/Profile.js b/client/src/components/profile/Profile.js new file mode 100644 index 0000000000..33b8a23105 --- /dev/null +++ b/client/src/components/profile/Profile.js @@ -0,0 +1,176 @@ +import React, { Fragment } from 'react'; +import PropTypes from 'prop-types'; +import { Alert, Button, Grid, Row, Col } from '@freecodecamp/react-bootstrap'; +import Helmet from 'react-helmet'; +import { Link } from 'gatsby'; + +import Layout from '../layouts/Default'; +import CurrentChallengeLink from '../helpers/CurrentChallengeLink'; +import FullWidthRow from '../helpers/FullWidthRow'; +import Spacer from '../helpers/Spacer'; +import Camper from './components/Camper'; +import HeatMap from './components/HeatMap'; +import Certifications from './components/Certifications'; +import Portfolio from './components/Portfolio'; +import Timeline from './components/TimeLine'; + +const propTypes = { + isSessionUser: PropTypes.bool, + user: PropTypes.shape({ + profileUI: PropTypes.shape({ + isLocked: PropTypes.bool, + showAbout: PropTypes.bool, + showCerts: PropTypes.bool, + showHeatMap: PropTypes.bool, + showLocation: PropTypes.bool, + showName: PropTypes.bool, + showPoints: PropTypes.bool, + showPortfolio: PropTypes.bool, + showTimeLine: PropTypes.bool + }), + username: PropTypes.string + }) +}; + +function TakeMeToTheChallenges() { + return ( + + + + ); +} + +function renderIsLocked(username) { + return ( + + + {username} | freeCodeCamp.org + + + + +

+ {username} has not made their profile public. +

+
+ + +

+ {username} needs to change their privacy setting in order for you + to view their profile +

+
+
+ + + +
+
+ ); +} + +function renderSettingsButton() { + return ( + + + + + + + + + + + ); +} + +function Profile({ user, isSessionUser }) { + const { + profileUI: { + isLocked = true, + showAbout = false, + showCerts = false, + showHeatMap = false, + showLocation = false, + showName = false, + showPoints = false, + showPortfolio = false, + showTimeLine = false + }, + calendar, + completedChallenges, + streak, + githubProfile, + isLinkedIn, + isGithub, + isTwitter, + isWebsite, + linkedin, + twitter, + website, + name, + username, + location, + points, + picture, + portfolio, + about, + yearsTopContributor + } = user; + + if (isLocked) { + return renderIsLocked(username); + } + return ( + + + {username} | freeCodeCamp.org + + + + {isSessionUser ? renderSettingsButton() : null} + + + {showHeatMap ? : null} + {showCerts ? : null} + {showPortfolio ? : null} + {showTimeLine ? ( + + ) : null} + + ); +} + +Profile.displayName = 'Profile'; +Profile.propTypes = propTypes; + +export default Profile; diff --git a/client/src/components/profile/components/Camper.js b/client/src/components/profile/components/Camper.js new file mode 100644 index 0000000000..49d1f291e4 --- /dev/null +++ b/client/src/components/profile/components/Camper.js @@ -0,0 +1,113 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Col, Row, Image } from '@freecodecamp/react-bootstrap'; +import FontAwesomeIcon from '@fortawesome/react-fontawesome'; +import { faAward } from '@fortawesome/free-solid-svg-icons'; + +import SocialIcons from './SocialIcons'; + +import './camper.css'; + +const propTypes = { + about: PropTypes.string, + githubProfile: PropTypes.string, + isGithub: PropTypes.bool, + isLinkedIn: PropTypes.bool, + isTwitter: PropTypes.bool, + isWebsite: PropTypes.bool, + linkedin: PropTypes.string, + location: PropTypes.string, + name: PropTypes.string, + picture: PropTypes.string, + points: PropTypes.number, + twitter: PropTypes.string, + username: PropTypes.string, + website: PropTypes.string, + yearsTopContributor: PropTypes.array +}; + +function pluralise(word, condition) { + return condition ? word + 's' : word; +} + +function joinArray(array) { + return array.reduce((string, item, index, array) => { + if (string.length > 0) { + if (index === array.length - 1) { + return `${string} and ${item}`; + } else { + return `${string}, ${item}`; + } + } else { + return item; + } + }); +} + +function Camper({ + name, + username, + location, + points, + picture, + about, + yearsTopContributor, + githubProfile, + isLinkedIn, + isGithub, + isTwitter, + isWebsite, + linkedin, + twitter, + website +}) { + return ( +
+ + + {username + + + +
+

@{username}

+ {name &&

{name}

} + {location &&

{location}

} + {about &&

{about}

} + {typeof points === 'number' ? ( +

+ {`${points} ${pluralise('point', points !== 1)}`} +

+ ) : null} + {yearsTopContributor.filter(Boolean).length > 0 && ( +
+
+

+ Top Contributor +

+

{joinArray(yearsTopContributor)}

+
+ )} +
+
+ ); +} + +Camper.displayName = 'Camper'; +Camper.propTypes = propTypes; + +export default Camper; diff --git a/client/src/components/profile/components/Certifications.js b/client/src/components/profile/components/Certifications.js new file mode 100644 index 0000000000..a781a4550d --- /dev/null +++ b/client/src/components/profile/components/Certifications.js @@ -0,0 +1,165 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { curry } from 'lodash'; +import { createSelector } from 'reselect'; +import { connect } from 'react-redux'; +import { Button, Row, Col } from '@freecodecamp/react-bootstrap'; + +import { userByNameSelector } from '../../../redux'; +import FullWidthRow from '../../helpers/FullWidthRow'; + +const mapStateToProps = (state, props) => + createSelector( + userByNameSelector(props.username), + ({ + isRespWebDesignCert, + is2018DataVisCert, + isFrontEndLibsCert, + isJsAlgoDataStructCert, + isApisMicroservicesCert, + isInfosecQaCert, + isFrontEndCert, + isBackEndCert, + isDataVisCert, + isFullStackCert + }) => ({ + hasModernCert: + isRespWebDesignCert || + is2018DataVisCert || + isFrontEndLibsCert || + isJsAlgoDataStructCert || + isApisMicroservicesCert || + isInfosecQaCert || + isFullStackCert, + hasLegacyCert: isFrontEndCert || isBackEndCert || isDataVisCert, + currentCerts: [ + { + show: isFullStackCert, + title: 'Full Stack Certification', + showURL: 'full-stack' + }, + { + show: isRespWebDesignCert, + title: 'Responsive Web Design Certification', + showURL: 'responsive-web-design' + }, + { + show: isJsAlgoDataStructCert, + title: 'JavaScript Algorithms and Data Structures Certification', + showURL: 'javascript-algorithms-and-data-structures' + }, + { + show: isFrontEndLibsCert, + title: 'Front End Libraries Certification', + showURL: 'front-end-libraries' + }, + { + show: is2018DataVisCert, + title: 'Data Visualization Certification', + showURL: 'data-visualization' + }, + { + show: isApisMicroservicesCert, + title: 'APIs and Microservices Certification', + showURL: 'apis-and-microservices' + }, + { + show: isInfosecQaCert, + title: 'Information Security and Quality Assurance Certification', + showURL: 'information-security-and-quality-assurance' + } + ], + legacyCerts: [ + { + show: isFullStackCert, + title: 'Full Stack Certification', + showURL: 'legacy-full-stack' + }, + { + show: isFrontEndCert, + title: 'Front End Certification', + showURL: 'legacy-front-end' + }, + { + show: isBackEndCert, + title: 'Back End Certification', + showURL: 'legacy-back-end' + }, + { + show: isDataVisCert, + title: 'Data Visualization Certification', + showURL: 'legacy-data-visualization' + } + ] + }) + )(state, props); + +const certArrayTypes = PropTypes.arrayOf( + PropTypes.shape({ + show: PropTypes.bool, + title: PropTypes.string, + showURL: PropTypes.string + }) +); + +const propTypes = { + currentCerts: certArrayTypes, + hasLegacyCert: PropTypes.bool, + hasModernCert: PropTypes.bool, + legacyCerts: certArrayTypes, + username: PropTypes.string +}; + +function renderCertShow(username, cert) { + return cert.show ? ( + + + + + + ) : null; +} + +function Certificates({ + currentCerts, + legacyCerts, + hasLegacyCert, + hasModernCert, + username +}) { + const renderCertShowWithUsername = curry(renderCertShow)(username); + return ( + +

freeCodeCamp Certifications

+
+ {hasModernCert ? ( + currentCerts.map(renderCertShowWithUsername) + ) : ( +

+ No certifications have been earned under the current curriculum +

+ )} + {hasLegacyCert ? ( +
+
+

Legacy Certifications

+
+ {legacyCerts.map(renderCertShowWithUsername)} +
+ ) : null} +
+
+ ); +} + +Certificates.propTypes = propTypes; +Certificates.displayName = 'Certificates'; + +export default connect(mapStateToProps)(Certificates); diff --git a/client/src/components/profile/components/HeatMap.js b/client/src/components/profile/components/HeatMap.js new file mode 100644 index 0000000000..550c7ba6da --- /dev/null +++ b/client/src/components/profile/components/HeatMap.js @@ -0,0 +1,60 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { Helmet } from 'react-helmet'; + +import FullWidthRow from '../../helpers/FullWidthRow'; + +import './heatmap.css'; + +const propTypes = { + calendar: PropTypes.object, + streak: PropTypes.shape({ + current: PropTypes.number, + longest: PropTypes.number + }) +}; + +class HeatMap extends Component { + constructor(props) { + super(props); + + this.renderMap = this.renderMap.bind(this); + } + + render() { + const { streak = {} } = this.props; + return ( + + +