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 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}
|
||||
/>
|
||||
<Spacer />
|
||||
{/* <PortfolioSettings />
|
||||
<Portfolio portfolio={portfolio} updatePortfolio={updatePortfolio} />
|
||||
<Spacer />
|
||||
<Honesty />
|
||||
{/* <Honesty />
|
||||
<Spacer />
|
||||
<CertificationSettings />
|
||||
<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
|
||||
export const maybeEmailRE = /.*@.*\.\w\w/;
|
||||
export const maybeUrlRE = /https?:\/\/.*\..*/;
|
||||
export const hasProtocolRE = /^http/;
|
||||
|
Reference in New Issue
Block a user