feat(portfolio): Add portfolio settings
This commit is contained in:
@ -17,6 +17,7 @@ import About from '../components/settings/About';
|
|||||||
import Privacy from '../components/settings/Privacy';
|
import Privacy from '../components/settings/Privacy';
|
||||||
import Email from '../components/settings/Email';
|
import Email from '../components/settings/Email';
|
||||||
import Internet from '../components/settings/Internet';
|
import Internet from '../components/settings/Internet';
|
||||||
|
import Portfolio from '../components/settings/Portfolio';
|
||||||
|
|
||||||
const propTypes = {
|
const propTypes = {
|
||||||
about: PropTypes.string,
|
about: PropTypes.string,
|
||||||
@ -28,6 +29,15 @@ const propTypes = {
|
|||||||
name: PropTypes.string,
|
name: PropTypes.string,
|
||||||
picture: PropTypes.string,
|
picture: PropTypes.string,
|
||||||
points: PropTypes.number,
|
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,
|
sendQuincyEmail: PropTypes.bool,
|
||||||
showLoading: PropTypes.bool,
|
showLoading: PropTypes.bool,
|
||||||
submitNewAbout: PropTypes.func.isRequired,
|
submitNewAbout: PropTypes.func.isRequired,
|
||||||
@ -35,6 +45,7 @@ const propTypes = {
|
|||||||
toggleNightMode: PropTypes.func.isRequired,
|
toggleNightMode: PropTypes.func.isRequired,
|
||||||
twitter: PropTypes.string,
|
twitter: PropTypes.string,
|
||||||
updateInternetSettings: PropTypes.func.isRequired,
|
updateInternetSettings: PropTypes.func.isRequired,
|
||||||
|
updatePortfolio: PropTypes.func.isRequired,
|
||||||
updateQuincyEmail: PropTypes.func.isRequired,
|
updateQuincyEmail: PropTypes.func.isRequired,
|
||||||
username: PropTypes.string,
|
username: PropTypes.string,
|
||||||
website: PropTypes.string
|
website: PropTypes.string
|
||||||
@ -59,7 +70,8 @@ const mapStateToProps = createSelector(
|
|||||||
githubProfile,
|
githubProfile,
|
||||||
linkedin,
|
linkedin,
|
||||||
twitter,
|
twitter,
|
||||||
website
|
website,
|
||||||
|
portfolio
|
||||||
}
|
}
|
||||||
) => ({
|
) => ({
|
||||||
email,
|
email,
|
||||||
@ -76,7 +88,8 @@ const mapStateToProps = createSelector(
|
|||||||
githubProfile,
|
githubProfile,
|
||||||
linkedin,
|
linkedin,
|
||||||
twitter,
|
twitter,
|
||||||
website
|
website,
|
||||||
|
portfolio
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -86,6 +99,7 @@ const mapDispatchToProps = dispatch =>
|
|||||||
submitNewAbout,
|
submitNewAbout,
|
||||||
toggleNightMode: theme => updateUserFlag({ theme }),
|
toggleNightMode: theme => updateUserFlag({ theme }),
|
||||||
updateInternetSettings: updateUserFlag,
|
updateInternetSettings: updateUserFlag,
|
||||||
|
updatePortfolio: updateUserFlag,
|
||||||
updateQuincyEmail: sendQuincyEmail => updateUserFlag({ sendQuincyEmail })
|
updateQuincyEmail: sendQuincyEmail => updateUserFlag({ sendQuincyEmail })
|
||||||
},
|
},
|
||||||
dispatch
|
dispatch
|
||||||
@ -111,7 +125,9 @@ function ShowSettings(props) {
|
|||||||
linkedin,
|
linkedin,
|
||||||
twitter,
|
twitter,
|
||||||
website,
|
website,
|
||||||
updateInternetSettings
|
updateInternetSettings,
|
||||||
|
portfolio,
|
||||||
|
updatePortfolio
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
if (showLoading) {
|
if (showLoading) {
|
||||||
@ -182,9 +198,9 @@ function ShowSettings(props) {
|
|||||||
website={website}
|
website={website}
|
||||||
/>
|
/>
|
||||||
<Spacer />
|
<Spacer />
|
||||||
{/* <PortfolioSettings />
|
<Portfolio portfolio={portfolio} updatePortfolio={updatePortfolio} />
|
||||||
<Spacer />
|
<Spacer />
|
||||||
<Honesty />
|
{/* <Honesty />
|
||||||
<Spacer />
|
<Spacer />
|
||||||
<CertificationSettings />
|
<CertificationSettings />
|
||||||
<Spacer />
|
<Spacer />
|
||||||
|
342
client/src/components/settings/Portfolio.js
Normal file
342
client/src/components/settings/Portfolio.js
Normal file
@ -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 (
|
||||||
|
<div key={id}>
|
||||||
|
<FullWidthRow>
|
||||||
|
<form onSubmit={this.handleSubmit}>
|
||||||
|
<FormGroup
|
||||||
|
controlId={`${id}-title`}
|
||||||
|
validationState={
|
||||||
|
pristine || (!pristine && !title) ? null : titleState
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ControlLabel>Title</ControlLabel>
|
||||||
|
<FormControl
|
||||||
|
onChange={this.createOnChangeHandler(id, 'title')}
|
||||||
|
required={true}
|
||||||
|
type='text'
|
||||||
|
value={title}
|
||||||
|
/>
|
||||||
|
{titleMessage ? <HelpBlock>{titleMessage}</HelpBlock> : null}
|
||||||
|
</FormGroup>
|
||||||
|
<FormGroup
|
||||||
|
controlId={`${id}-url`}
|
||||||
|
validationState={
|
||||||
|
pristine || (!pristine && !url) ? null : urlState
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ControlLabel>URL</ControlLabel>
|
||||||
|
<FormControl
|
||||||
|
onChange={this.createOnChangeHandler(id, 'url')}
|
||||||
|
required={true}
|
||||||
|
type='url'
|
||||||
|
value={url}
|
||||||
|
/>
|
||||||
|
{urlMessage ? <HelpBlock>{urlMessage}</HelpBlock> : null}
|
||||||
|
</FormGroup>
|
||||||
|
<FormGroup
|
||||||
|
controlId={`${id}-image`}
|
||||||
|
validationState={pristine ? null : imageState}
|
||||||
|
>
|
||||||
|
<ControlLabel>Image</ControlLabel>
|
||||||
|
<FormControl
|
||||||
|
onChange={this.createOnChangeHandler(id, 'image')}
|
||||||
|
type='url'
|
||||||
|
value={image}
|
||||||
|
/>
|
||||||
|
{imageMessage ? <HelpBlock>{imageMessage}</HelpBlock> : null}
|
||||||
|
</FormGroup>
|
||||||
|
<FormGroup
|
||||||
|
controlId={`${id}-description`}
|
||||||
|
validationState={pristine ? null : descriptionState}
|
||||||
|
>
|
||||||
|
<ControlLabel>Description</ControlLabel>
|
||||||
|
<FormControl
|
||||||
|
componentClass='textarea'
|
||||||
|
onChange={this.createOnChangeHandler(id, 'description')}
|
||||||
|
value={description}
|
||||||
|
/>
|
||||||
|
{descriptionMessage ? (
|
||||||
|
<HelpBlock>{descriptionMessage}</HelpBlock>
|
||||||
|
) : null}
|
||||||
|
</FormGroup>
|
||||||
|
<BlockSaveButton
|
||||||
|
disabled={
|
||||||
|
pristine ||
|
||||||
|
!title ||
|
||||||
|
!isURL(url, {
|
||||||
|
protocols: ['http', 'https'],
|
||||||
|
/* eslint-disable camelcase */
|
||||||
|
require_tld: true,
|
||||||
|
require_protocol: true
|
||||||
|
/* eslint-enable camelcase */
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Save this portfolio item
|
||||||
|
</BlockSaveButton>
|
||||||
|
<ButtonSpacer />
|
||||||
|
<Button
|
||||||
|
block={true}
|
||||||
|
bsSize='lg'
|
||||||
|
bsStyle='danger'
|
||||||
|
className='btn-delete-portfolio'
|
||||||
|
onClick={() => this.handleRemoveItem(id)}
|
||||||
|
type='button'
|
||||||
|
>
|
||||||
|
Remove this portfolio item
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
{index + 1 !== arr.length && (
|
||||||
|
<Fragment>
|
||||||
|
<Spacer />
|
||||||
|
<hr />
|
||||||
|
<Spacer />
|
||||||
|
</Fragment>
|
||||||
|
)}
|
||||||
|
</FullWidthRow>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { portfolio = [] } = this.state;
|
||||||
|
return (
|
||||||
|
<section id='portfolio-settings'>
|
||||||
|
<SectionHeader>Portfolio Settings</SectionHeader>
|
||||||
|
<FullWidthRow>
|
||||||
|
<div className='portfolio-settings-intro'>
|
||||||
|
<p className='p-intro'>
|
||||||
|
Share your non-FreeCodeCamp projects, articles or accepted pull
|
||||||
|
requests.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</FullWidthRow>
|
||||||
|
<FullWidthRow>
|
||||||
|
<ButtonSpacer />
|
||||||
|
<Button
|
||||||
|
block={true}
|
||||||
|
bsSize='lg'
|
||||||
|
bsStyle='primary'
|
||||||
|
onClick={this.handleAdd}
|
||||||
|
type='button'
|
||||||
|
>
|
||||||
|
Add a new portfolio Item
|
||||||
|
</Button>
|
||||||
|
</FullWidthRow>
|
||||||
|
<Spacer size={2} />
|
||||||
|
{portfolio.length ? portfolio.map(this.renderPortfolio) : null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PortfolioSettings.displayName = 'PortfolioSettings';
|
||||||
|
PortfolioSettings.propTypes = propTypes;
|
||||||
|
|
||||||
|
export default PortfolioSettings;
|
4
client/src/components/settings/portfolio.css
Normal file
4
client/src/components/settings/portfolio.css
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
.btn-delete-portfolio {
|
||||||
|
background-color: #fff;
|
||||||
|
color: #f11e00
|
||||||
|
}
|
@ -3,3 +3,4 @@
|
|||||||
// before we try to validate
|
// before we try to validate
|
||||||
export const maybeEmailRE = /.*@.*\.\w\w/;
|
export const maybeEmailRE = /.*@.*\.\w\w/;
|
||||||
export const maybeUrlRE = /https?:\/\/.*\..*/;
|
export const maybeUrlRE = /https?:\/\/.*\..*/;
|
||||||
|
export const hasProtocolRE = /^http/;
|
||||||
|
Reference in New Issue
Block a user