From ef0428b3961b8f81c54a3c89ba2772f415ed1e0f Mon Sep 17 00:00:00 2001 From: Bouncey Date: Thu, 20 Sep 2018 16:27:06 +0100 Subject: [PATCH] feat(portfolio): Add portfolio settings --- client/src/client-only-routes/ShowSettings.js | 26 +- client/src/components/settings/Portfolio.js | 342 ++++++++++++++++++ client/src/components/settings/portfolio.css | 4 + client/src/utils/index.js | 1 + 4 files changed, 368 insertions(+), 5 deletions(-) create mode 100644 client/src/components/settings/Portfolio.js create mode 100644 client/src/components/settings/portfolio.css diff --git a/client/src/client-only-routes/ShowSettings.js b/client/src/client-only-routes/ShowSettings.js index 74814afd8c..6bf898e448 100644 --- a/client/src/client-only-routes/ShowSettings.js +++ b/client/src/client-only-routes/ShowSettings.js @@ -17,6 +17,7 @@ import About from '../components/settings/About'; import Privacy from '../components/settings/Privacy'; import Email from '../components/settings/Email'; import Internet from '../components/settings/Internet'; +import Portfolio from '../components/settings/Portfolio'; const propTypes = { about: PropTypes.string, @@ -28,6 +29,15 @@ const propTypes = { name: PropTypes.string, picture: PropTypes.string, points: PropTypes.number, + portfolio: PropTypes.arrayOf( + PropTypes.shape({ + id: PropTypes.string.isRequired, + title: PropTypes.string, + url: PropTypes.string, + image: PropTypes.string, + description: PropTypes.string + }) + ), sendQuincyEmail: PropTypes.bool, showLoading: PropTypes.bool, submitNewAbout: PropTypes.func.isRequired, @@ -35,6 +45,7 @@ const propTypes = { toggleNightMode: PropTypes.func.isRequired, twitter: PropTypes.string, updateInternetSettings: PropTypes.func.isRequired, + updatePortfolio: PropTypes.func.isRequired, updateQuincyEmail: PropTypes.func.isRequired, username: PropTypes.string, website: PropTypes.string @@ -59,7 +70,8 @@ const mapStateToProps = createSelector( githubProfile, linkedin, twitter, - website + website, + portfolio } ) => ({ email, @@ -76,7 +88,8 @@ const mapStateToProps = createSelector( githubProfile, linkedin, twitter, - website + website, + portfolio }) ); @@ -86,6 +99,7 @@ const mapDispatchToProps = dispatch => submitNewAbout, toggleNightMode: theme => updateUserFlag({ theme }), updateInternetSettings: updateUserFlag, + updatePortfolio: updateUserFlag, updateQuincyEmail: sendQuincyEmail => updateUserFlag({ sendQuincyEmail }) }, dispatch @@ -111,7 +125,9 @@ function ShowSettings(props) { linkedin, twitter, website, - updateInternetSettings + updateInternetSettings, + portfolio, + updatePortfolio } = props; if (showLoading) { @@ -182,9 +198,9 @@ function ShowSettings(props) { website={website} /> - {/* + - + {/* diff --git a/client/src/components/settings/Portfolio.js b/client/src/components/settings/Portfolio.js new file mode 100644 index 0000000000..52cc03b9a0 --- /dev/null +++ b/client/src/components/settings/Portfolio.js @@ -0,0 +1,342 @@ +import React, { PureComponent, Fragment } from 'react'; +import PropTypes from 'prop-types'; +import nanoid from 'nanoid'; +import { + Button, + FormGroup, + ControlLabel, + FormControl, + HelpBlock +} from '@freecodecamp/react-bootstrap'; +import { findIndex, find, isEqual } from 'lodash'; +import isURL from 'validator/lib/isURL'; + +import { hasProtocolRE } from '../../utils'; + +import { FullWidthRow, ButtonSpacer, Spacer } from '../helpers'; +import SectionHeader from './SectionHeader'; +import BlockSaveButton from '../helpers/form/BlockSaveButton'; + +import './portfolio.css'; + +const propTypes = { + picture: PropTypes.string, + portfolio: PropTypes.arrayOf( + PropTypes.shape({ + description: PropTypes.string, + image: PropTypes.string, + title: PropTypes.string, + url: PropTypes.string + }) + ), + updatePortfolio: PropTypes.func.isRequired, + username: PropTypes.string +}; + +function createEmptyPortfolio() { + return { + id: nanoid(), + title: '', + description: '', + url: '', + image: '' + }; +} + +function createFindById(id) { + return p => p.id === id; +} + +const mockEvent = { + preventDefault() {} +}; + +class PortfolioSettings extends PureComponent { + constructor(props) { + super(props); + + const { portfolio = [] } = props; + + this.state = { + portfolio: [...portfolio] + }; + } + + createOnChangeHandler = (id, key) => e => { + e.preventDefault(); + const userInput = e.target.value.slice(); + return this.setState(state => { + const { portfolio: currentPortfolio } = state; + const mutatblePortfolio = currentPortfolio.slice(0); + const index = findIndex(currentPortfolio, p => p.id === id); + + mutatblePortfolio[index] = { + ...mutatblePortfolio[index], + [key]: userInput + }; + + return { portfolio: mutatblePortfolio }; + }); + }; + + handleSubmit = e => { + e.preventDefault(); + const { updatePortfolio } = this.props; + const { portfolio } = this.state; + return updatePortfolio({ portfolio }); + }; + + handleAdd = () => { + return this.setState(state => ({ + portfolio: [createEmptyPortfolio(), ...state.portfolio] + })); + }; + + handleRemoveItem = id => { + return this.setState( + state => ({ + portfolio: state.portfolio.filter(p => p.id !== id) + }), + () => this.handleSubmit(mockEvent) + ); + }; + + isFormPristine = id => { + const { portfolio } = this.state; + const { portfolio: originalPortfolio } = this.props; + const original = find(originalPortfolio, createFindById(id)); + if (!original) { + return false; + } + const edited = find(portfolio, createFindById(id)); + return isEqual(original, edited); + }; + + isFormValid = id => { + const { portfolio } = this.state; + const toValidate = find(portfolio, createFindById(id)); + if (!toValidate) { + return false; + } + const { title, url, image, description } = toValidate; + + const { state: titleState } = this.getTitleValidation(title); + const { state: urlState } = this.getUrlValidation(url); + const { state: imageState } = this.getUrlValidation(image, true); + const { state: descriptionState } = this.getDescriptionValidation( + description + ); + return [titleState, imageState, urlState, descriptionState] + .filter(Boolean) + .every(state => state === 'success'); + }; + + getDescriptionValidation(description) { + const len = description.length; + const charsLeft = 288 - len; + if (charsLeft < 0) { + return { + state: 'error', + message: 'There is a maxiumum limit of 288 characters, you have 0 left' + }; + } + if (charsLeft < 41 && charsLeft > 0) { + return { + state: 'warning', + message: + 'There is a maxiumum limit of 288 characters, you have ' + + charsLeft + + ' left' + }; + } + if (charsLeft === 288) { + return { state: null, message: '' }; + } + return { state: 'success', message: '' }; + } + + getTitleValidation(title) { + if (!title) { + return { state: 'error', message: 'A title is required' }; + } + const len = title.length; + if (len < 2) { + return { state: 'error', message: 'Title is too short' }; + } + if (len > 144) { + return { state: 'error', message: 'Title is too long' }; + } + return { state: 'success', message: '' }; + } + + getUrlValidation(maybeUrl, isImage) { + const len = maybeUrl.length; + if (len >= 4 && !hasProtocolRE.test(maybeUrl)) { + return { state: 'error', message: 'URL must start with http or https' }; + } + if (isImage && !maybeUrl) { + return { state: null, message: '' }; + } + if (isImage && !(/\.(png|jpg|jpeg|gif)$/).test(maybeUrl)) { + return { + state: 'error', + message: 'URL must link directly to an image file' + }; + } + return isURL(maybeUrl) + ? { state: 'success', message: '' } + : { state: 'warning', message: 'Please use a valid URL' }; + } + + renderPortfolio = (portfolio, index, arr) => { + const { id, title, description, url, image } = portfolio; + const pristine = this.isFormPristine(id); + const { + state: titleState, + message: titleMessage + } = this.getTitleValidation(title); + const { state: urlState, message: urlMessage } = this.getUrlValidation(url); + const { state: imageState, message: imageMessage } = this.getUrlValidation( + image, + true + ); + const { + state: descriptionState, + message: descriptionMessage + } = this.getDescriptionValidation(description); + + return ( +
+ +
+ + Title + + {titleMessage ? {titleMessage} : null} + + + URL + + {urlMessage ? {urlMessage} : null} + + + Image + + {imageMessage ? {imageMessage} : null} + + + Description + + {descriptionMessage ? ( + {descriptionMessage} + ) : null} + + + Save this portfolio item + + + + + {index + 1 !== arr.length && ( + + +
+ +
+ )} +
+
+ ); + }; + + render() { + const { portfolio = [] } = this.state; + return ( +
+ Portfolio Settings + +
+

+ Share your non-FreeCodeCamp projects, articles or accepted pull + requests. +

+
+
+ + + + + + {portfolio.length ? portfolio.map(this.renderPortfolio) : null} +
+ ); + } +} + +PortfolioSettings.displayName = 'PortfolioSettings'; +PortfolioSettings.propTypes = propTypes; + +export default PortfolioSettings; diff --git a/client/src/components/settings/portfolio.css b/client/src/components/settings/portfolio.css new file mode 100644 index 0000000000..1cbbc68564 --- /dev/null +++ b/client/src/components/settings/portfolio.css @@ -0,0 +1,4 @@ +.btn-delete-portfolio { + background-color: #fff; + color: #f11e00 +} \ No newline at end of file diff --git a/client/src/utils/index.js b/client/src/utils/index.js index 32f22feef1..d9d2a7fa53 100644 --- a/client/src/utils/index.js +++ b/client/src/utils/index.js @@ -3,3 +3,4 @@ // before we try to validate export const maybeEmailRE = /.*@.*\.\w\w/; export const maybeUrlRE = /https?:\/\/.*\..*/; +export const hasProtocolRE = /^http/;