340 lines
9.5 KiB
JavaScript
340 lines
9.5 KiB
JavaScript
import React, { Component, 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';
|
|
|
|
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 Component {
|
|
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'
|
|
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;
|