Create Collection

This commit is contained in:
Andrew Nicolaou 2019-07-09 18:24:09 +02:00 committed by Cassie Tarakajian
parent 6ca6e78a28
commit c57ead4c62
13 changed files with 394 additions and 12 deletions

View File

@ -37,6 +37,10 @@ export const SET_PROJECT = 'SET_PROJECT';
export const SET_PROJECTS = 'SET_PROJECTS';
export const SET_COLLECTIONS = 'SET_COLLECTIONS';
export const CREATE_COLLECTION = 'CREATED_COLLECTION';
export const ADD_TO_COLLECTION = 'ADD_TO_COLLECTION';
export const REMOVE_FROM_COLLECTION = 'REMOVE_FROM_COLLECTION';
export const DELETE_PROJECT = 'DELETE_PROJECT';

View File

@ -32,3 +32,80 @@ export function getCollections(username) {
});
};
}
export function createCollection(collection) {
return (dispatch) => {
dispatch(startLoader());
const url = `${ROOT_URL}/collections`;
return axios.post(url, collection, { withCredentials: true })
.then((response) => {
dispatch({
type: ActionTypes.CREATE_COLLECTION
});
dispatch(stopLoader());
return response.data;
})
.catch((response) => {
dispatch({
type: ActionTypes.ERROR,
error: response.data
});
dispatch(stopLoader());
return response.data;
});
};
}
export function addToCollection(collectionId, projectId) {
return (dispatch) => {
dispatch(startLoader());
const url = `${ROOT_URL}/collections/${collectionId}/${projectId}`;
return axios.post(url, { withCredentials: true })
.then((response) => {
dispatch({
type: ActionTypes.ADD_TO_COLLECTION,
payload: response.data
});
dispatch(stopLoader());
return response.data;
})
.catch((response) => {
dispatch({
type: ActionTypes.ERROR,
error: response.data
});
dispatch(stopLoader());
return response.data;
});
};
}
export function removeFromCollection(collectionId, projectId) {
return (dispatch) => {
dispatch(startLoader());
const url = `${ROOT_URL}/collections/${collectionId}/${projectId}`;
return axios.delete(url, { withCredentials: true })
.then((response) => {
dispatch({
type: ActionTypes.REMOVE_FROM_COLLECTION,
payload: response.data
});
dispatch(stopLoader());
return response.data;
})
.catch((response) => {
dispatch({
type: ActionTypes.ERROR,
error: response.data
});
dispatch(stopLoader());
return response.data;
});
};
}

View File

@ -21,6 +21,10 @@ const arrowDown = require('../../../images/sort-arrow-down.svg');
const downFilledTriangle = require('../../../images/down-filled-triangle.svg');
class CollectionListRowBase extends React.Component {
static projectInCollection(project, collection) {
return collection.items.find(item => item.project.id === project.id) != null;
}
constructor(props) {
super(props);
this.state = {
@ -134,6 +138,14 @@ class CollectionListRowBase extends React.Component {
}
}
handleCollectionAdd = () => {
this.props.addToCollection(this.props.collection.id, this.props.project.id);
}
handleCollectionRemove = () => {
this.props.removeFromCollection(this.props.collection.id, this.props.project.id);
}
render() {
const { collection, username } = this.props;
const { renameOpen, optionsOpen, renameValue } = this.state;
@ -198,6 +210,8 @@ class CollectionListRowBase extends React.Component {
}
CollectionListRowBase.propTypes = {
addToCollection: PropTypes.func.isRequired,
removeFromCollection: PropTypes.func.isRequired,
collection: PropTypes.shape({
id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired
@ -215,7 +229,7 @@ CollectionListRowBase.propTypes = {
};
function mapDispatchToPropsSketchListRow(dispatch) {
return bindActionCreators(Object.assign({}, ProjectActions, IdeActions), dispatch);
return bindActionCreators(Object.assign({}, CollectionsActions, ProjectActions, IdeActions), dispatch);
}
const CollectionListRow = connect(null, mapDispatchToPropsSketchListRow)(CollectionListRowBase);
@ -279,6 +293,9 @@ class CollectionList extends React.Component {
<Helmet>
<title>{this.getTitle()}</title>
</Helmet>
<Link to={`/${username}/collections/create`}>New collection</Link>
{this._renderLoader()}
{this._renderEmptyTable()}
{this.hasCollections() &&

View File

@ -4,6 +4,18 @@ const sketches = (state = [], action) => {
switch (action.type) {
case ActionTypes.SET_COLLECTIONS:
return action.collections;
// The API returns the complete new collection
// with the items added or removed
case ActionTypes.ADD_TO_COLLECTION:
case ActionTypes.REMOVE_FROM_COLLECTION:
return state.map((collection) => {
if (collection.id === action.payload.id) {
return action.payload;
}
return collection;
});
default:
return state;
}

View File

@ -1,14 +1,8 @@
import friendlyWords from 'friendly-words';
import * as ActionTypes from '../../../constants';
const generateRandomName = () => {
const adj = friendlyWords.predicates[Math.floor(Math.random() * friendlyWords.predicates.length)];
const obj = friendlyWords.objects[Math.floor(Math.random() * friendlyWords.objects.length)];
return `${adj} ${obj}`;
};
import { generateProjectName } from '../../../utils/generateRandomName';
const initialState = () => {
const generatedString = generateRandomName();
const generatedString = generateProjectName();
const generatedName = generatedString.charAt(0).toUpperCase() + generatedString.slice(1);
return {
name: generatedName,

View File

@ -0,0 +1,124 @@
import PropTypes from 'prop-types';
import React from 'react';
import { Helmet } from 'react-helmet';
import { connect } from 'react-redux';
import { browserHistory } from 'react-router';
import { bindActionCreators } from 'redux';
import * as CollectionsActions from '../../IDE/actions/collections';
import { generateCollectionName } from '../../../utils/generateRandomName';
class CollectionCreate extends React.Component {
constructor() {
super();
const name = generateCollectionName();
this.state = {
generatedCollectionName: name,
collection: {
name,
description: ''
}
};
}
getTitle() {
return 'p5.js Web Editor | Create collection';
}
handleTextChange = field => (evt) => {
this.setState({
collection: {
...this.state.collection,
[field]: evt.target.value,
}
});
}
handleCreateCollection = (event) => {
event.preventDefault();
this.props.createCollection(this.state.collection)
.then(({ id, owner }) => {
browserHistory.replace(`/${owner.username}/collections/${id}`);
})
.catch((error) => {
console.error('Error creating collection', error);
this.setState({
creationError: error,
});
});
}
render() {
const { generatedCollectionName, creationError } = this.state;
const { name, description } = this.state.collection;
const invalid = name === '' || name == null;
return (
<div className="sketches-table-container">
<Helmet>
<title>{this.getTitle()}</title>
</Helmet>
<form className="form" onSubmit={this.handleCreateCollection}>
{creationError && <span className="form-error">Couldn&apos;t create collection</span>}
<p className="form__field">
<label htmlFor="name" className="form__label">Collection name</label>
<input
className="form__input"
aria-label="name"
type="text"
id="name"
value={name}
placeholder={generatedCollectionName}
onChange={this.handleTextChange('name')}
/>
{invalid && <span className="form-error">Collection name is required</span>}
</p>
<p className="form__field">
<label htmlFor="description" className="form__label">Description (optional)</label>
<textarea
className="form__input form__input-flexible-height"
aria-label="description"
type="text"
id="description"
value={description}
onChange={this.handleTextChange('description')}
placeholder="My fave sketches"
rows="4"
/>
</p>
<input type="submit" disabled={invalid} value="Create collection" aria-label="create collection" />
</form>
</div>
);
}
}
CollectionCreate.propTypes = {
user: PropTypes.shape({
username: PropTypes.string,
authenticated: PropTypes.bool.isRequired
}).isRequired,
createCollection: PropTypes.func.isRequired,
collection: PropTypes.shape({}).isRequired, // TODO
sorting: PropTypes.shape({
field: PropTypes.string.isRequired,
direction: PropTypes.string.isRequired
}).isRequired
};
function mapStateToProps(state, ownProps) {
return {
user: state.user,
};
}
function mapDispatchToProps(dispatch) {
return bindActionCreators(Object.assign({}, CollectionsActions), dispatch);
}
export default connect(mapStateToProps, mapDispatchToProps)(CollectionCreate);

View File

@ -0,0 +1,114 @@
import PropTypes from 'prop-types';
import React from 'react';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import { browserHistory } from 'react-router';
import { updateSettings, initiateVerification, createApiKey, removeApiKey } from '../actions';
import NavBasic from '../../../components/NavBasic';
import CollectionCreate from '../components/CollectionCreate';
class CollectionView extends React.Component {
static defaultProps = {
user: null,
};
constructor(props) {
super(props);
this.closeAccountPage = this.closeAccountPage.bind(this);
this.gotoHomePage = this.gotoHomePage.bind(this);
}
componentDidMount() {
document.body.className = this.props.theme;
}
closeAccountPage() {
browserHistory.push(this.props.previousPath);
}
gotoHomePage() {
browserHistory.push('/');
}
ownerName() {
if (this.props.params.username) {
return this.props.params.username;
}
return this.props.user.username;
}
pageTitle() {
if (this.isCreatePage()) {
return 'Create collection';
}
return 'collection';
}
isOwner() {
return this.props.user.username === this.props.params.username;
}
isCreatePage() {
const path = this.props.location.pathname;
return /create$/.test(path);
}
renderContent() {
if (this.isCreatePage() && this.isOwner()) {
return <CollectionCreate />;
}
return 'collection page';
}
render() {
return (
<div className="dashboard">
<NavBasic onBack={this.closeAccountPage} />
<section className="dashboard-header">
<div className="dashboard-header__header">
<h2 className="dashboard-header__header__title">{this.pageTitle()}</h2>
</div>
<div className="dashboard-content">
{this.renderContent()}
</div>
</section>
</div>
);
}
}
function mapStateToProps(state) {
return {
previousPath: state.ide.previousPath,
user: state.user,
theme: state.preferences.theme
};
}
function mapDispatchToProps(dispatch) {
return bindActionCreators({
updateSettings, initiateVerification, createApiKey, removeApiKey
}, dispatch);
}
CollectionView.propTypes = {
location: PropTypes.shape({
pathname: PropTypes.string.isRequired,
}).isRequired,
params: PropTypes.shape({
username: PropTypes.string.isRequired,
}).isRequired,
previousPath: PropTypes.string.isRequired,
theme: PropTypes.string.isRequired,
user: PropTypes.shape({
username: PropTypes.string.isRequired,
}),
};
export default connect(mapStateToProps, mapDispatchToProps)(CollectionView);

View File

@ -9,6 +9,7 @@ import ResetPasswordView from './modules/User/pages/ResetPasswordView';
import EmailVerificationView from './modules/User/pages/EmailVerificationView';
import NewPasswordView from './modules/User/pages/NewPasswordView';
import AccountView from './modules/User/pages/AccountView';
import CollectionView from './modules/User/pages/CollectionView';
import DashboardView from './modules/User/pages/DashboardView';
import createRedirectWithUsername from './components/createRedirectWithUsername';
import { getUser } from './modules/User/actions';
@ -44,6 +45,7 @@ const routes = store => (
<Route path="/:username/sketches/:project_id" component={IDEView} />
<Route path="/:username/sketches" component={DashboardView} />
<Route path="/:username/collections" component={DashboardView} />
<Route path="/:username/collections/create" component={CollectionView} />
<Route path="/:username/collections/:collection_id" component={DashboardView} />
<Route path="/about" component={IDEView} />
<Route path="/feedback" component={IDEView} />

View File

@ -6,13 +6,13 @@ html, body {
font-size: #{$base-font-size}px;
}
body, input {
body, input, textarea {
@include themify() {
color: getThemifyVariable('primary-text-color');
}
}
body, input, button {
body, input, textarea, button {
font-family: Montserrat, sans-serif;
}
@ -31,7 +31,8 @@ input, button {
font-size: 1rem;
}
input {
input,
textarea {
padding: #{5 / $base-font-size}rem;
border: 1px solid ;
border-radius: 2px;

View File

@ -56,6 +56,10 @@
}
}
.form__input-flexible-height {
height: auto;
}
.form__input::placeholder {
@include themify() {
color: getThemifyVariable('form-input-placeholder-text-color');
@ -79,6 +83,15 @@
margin: #{39 / $base-font-size}rem 0 #{24 / $base-font-size}rem 0;
}
.form [type="submit"][disabled] {
cursor: not-allowed;
}
.form--inline [type="submit"] {
margin: 0 0 0 #{24 / $base-font-size}rem;
}
.form [type="submit"][disabled],
.form--inline [type="submit"][disabled] {
cursor: not-allowed;
}

View File

@ -0,0 +1,12 @@
import friendlyWords from 'friendly-words';
export function generateProjectName() {
const adj = friendlyWords.predicates[Math.floor(Math.random() * friendlyWords.predicates.length)];
const obj = friendlyWords.objects[Math.floor(Math.random() * friendlyWords.objects.length)];
return `${adj} ${obj}`;
}
export function generateCollectionName() {
const adj = friendlyWords.predicates[Math.floor(Math.random() * friendlyWords.predicates.length)];
return `My ${adj} collection`;
}

View File

@ -118,6 +118,18 @@ router.get('/:username/collections', (req, res) => {
));
});
router.get('/:username/collections/create', (req, res) => {
userExists(req.params.username, exists => (
exists ? res.send(renderIndex()) : get404Sketch(html => res.send(html))
));
});
router.get('/:username/sketches/:project_id/add-to-collection', (req, res) => {
projectForUserExists(req.params.username, req.params.project_id, exists => (
exists ? res.send(renderIndex()) : get404Sketch(html => res.send(html))
));
});
router.get('/:username/collections/:id', (req, res) => {
collectionForUserExists(req.params.username, req.params.id, exists => (
exists ? res.send(renderIndex()) : get404Sketch(html => res.send(html))