diff --git a/src/client/components/pages/collections.js b/src/client/components/pages/collections.js index aac7d5541b..789b0964cd 100644 --- a/src/client/components/pages/collections.js +++ b/src/client/components/pages/collections.js @@ -39,13 +39,8 @@ class CollectionsPage extends React.Component { } handleTypeChange(type) { - if (typeof type !== 'string') { - return; - } - const query = type ? `&type=${type}` : ''; - - this.setState({from: 0, query}); + this.setState({query}); } render() { diff --git a/src/client/components/pages/entities/author.js b/src/client/components/pages/entities/author.js index 873bdee112..11125434d9 100644 --- a/src/client/components/pages/entities/author.js +++ b/src/client/components/pages/entities/author.js @@ -104,7 +104,7 @@ AuthorAttributes.propTypes = { }; -function AuthorDisplayPage({entity, identifierTypes}) { +function AuthorDisplayPage({entity, identifierTypes, user}) { const urlPrefix = getEntityUrl(entity); return (
@@ -140,8 +140,10 @@ function AuthorDisplayPage({entity, identifierTypes}) {
); @@ -149,7 +151,9 @@ function AuthorDisplayPage({entity, identifierTypes}) { AuthorDisplayPage.displayName = 'AuthorDisplayPage'; AuthorDisplayPage.propTypes = { entity: PropTypes.object.isRequired, - identifierTypes: PropTypes.array + identifierTypes: PropTypes.array, + user: PropTypes.object.isRequired + }; AuthorDisplayPage.defaultProps = { identifierTypes: [] diff --git a/src/client/components/pages/entities/edition-group.js b/src/client/components/pages/entities/edition-group.js index bb456f5c8a..d2b7ae820a 100644 --- a/src/client/components/pages/entities/edition-group.js +++ b/src/client/components/pages/entities/edition-group.js @@ -18,7 +18,6 @@ import * as bootstrap from 'react-bootstrap'; import * as entityHelper from '../../../helpers/entity'; - import EditionTable from './edition-table'; import EntityFooter from './footer'; import EntityImage from './image'; @@ -62,7 +61,7 @@ EditionGroupAttributes.propTypes = { }; -function EditionGroupDisplayPage({entity, identifierTypes}) { +function EditionGroupDisplayPage({entity, identifierTypes, user}) { const urlPrefix = getEntityUrl(entity); return (
@@ -92,8 +91,10 @@ function EditionGroupDisplayPage({entity, identifierTypes}) {
); @@ -101,7 +102,8 @@ function EditionGroupDisplayPage({entity, identifierTypes}) { EditionGroupDisplayPage.displayName = 'EditionGroupDisplayPage'; EditionGroupDisplayPage.propTypes = { entity: PropTypes.object.isRequired, - identifierTypes: PropTypes.array + identifierTypes: PropTypes.array, + user: PropTypes.object.isRequired }; EditionGroupDisplayPage.defaultProps = { identifierTypes: [] diff --git a/src/client/components/pages/entities/edition.js b/src/client/components/pages/entities/edition.js index e2edb9cfa7..6b203a176d 100644 --- a/src/client/components/pages/entities/edition.js +++ b/src/client/components/pages/entities/edition.js @@ -18,7 +18,6 @@ import * as bootstrap from 'react-bootstrap'; import * as entityHelper from '../../../helpers/entity'; - import EntityFooter from './footer'; import EntityImage from './image'; import EntityLinks from './links'; @@ -100,7 +99,7 @@ EditionAttributes.propTypes = { }; -function EditionDisplayPage({entity, identifierTypes}) { +function EditionDisplayPage({entity, identifierTypes, user}) { // relationshipTypeId = 10 refers the relation ( is contained by ) const relationshipTypeId = 10; const worksContainedByEdition = getRelationshipTargetByTypeId(entity, relationshipTypeId); @@ -155,8 +154,10 @@ function EditionDisplayPage({entity, identifierTypes}) { ); @@ -164,7 +165,8 @@ function EditionDisplayPage({entity, identifierTypes}) { EditionDisplayPage.displayName = 'EditionDisplayPage'; EditionDisplayPage.propTypes = { entity: PropTypes.object.isRequired, - identifierTypes: PropTypes.array + identifierTypes: PropTypes.array, + user: PropTypes.object.isRequired }; EditionDisplayPage.defaultProps = { identifierTypes: [] diff --git a/src/client/components/pages/entities/footer.js b/src/client/components/pages/entities/footer.js index c76fdb8461..02a6bafda3 100644 --- a/src/client/components/pages/entities/footer.js +++ b/src/client/components/pages/entities/footer.js @@ -18,7 +18,7 @@ import * as bootstrap from 'react-bootstrap'; import * as utilsHelper from '../../../helpers/utils'; - +import AddToCollectionModal from '../parts/add-to-collection-modal'; import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; import PropTypes from 'prop-types'; import React from 'react'; @@ -26,64 +26,140 @@ import React from 'react'; const {formatDate} = utilsHelper; const { - Button, ButtonGroup, Col, Row + Alert, Button, ButtonGroup, Col, Row } = bootstrap; -function EntityFooter({bbid, deleted, entityUrl, lastModified}) { - return ( -
- - - - - - - - - - -
-
-
Last Modified
-
{formatDate(new Date(lastModified))}
-
+class EntityFooter extends React.Component { + constructor(props) { + super(props); + this.state = { + message: { + text: null, + type: null + }, + showModal: false + }; + + this.onCloseModal = this.onCloseModal.bind(this); + this.handleShowModal = this.handleShowModal.bind(this); + this.handleAlertDismiss = this.handleAlertDismiss.bind(this); + this.closeModalAndShowMessage = this.closeModalAndShowMessage.bind(this); + } + + onCloseModal() { + this.setState({showModal: false}); + } + + handleShowModal() { + if (this.props.user) { + this.setState({showModal: true}); + } + else { + this.setState({ + message: { + text: 'You need to be logged in', + type: 'danger' + } + }); + } + } + + closeModalAndShowMessage(message) { + this.setState({ + message, + showModal: false + }); + } + + handleAlertDismiss() { + this.setState({message: {}}); + } + + render() { + return ( +
+ { + this.props.user ? +
+ +
: null + } + { + this.state.message.text ? + {this.state.message.text} : null + + } + + + + + + + + + + + +
+
+
Last Modified
+
{formatDate(new Date(this.props.lastModified))}
+
+
-
- ); + ); + } } EntityFooter.displayName = 'EntityFooter'; EntityFooter.propTypes = { bbid: PropTypes.string.isRequired, deleted: PropTypes.bool, + entityType: PropTypes.string.isRequired, entityUrl: PropTypes.string.isRequired, - lastModified: PropTypes.oneOfType([PropTypes.string, PropTypes.instanceOf(Date)]).isRequired + lastModified: PropTypes.oneOfType([PropTypes.string, PropTypes.instanceOf(Date)]).isRequired, + user: PropTypes.object.isRequired }; EntityFooter.defaultProps = { deleted: false diff --git a/src/client/components/pages/entities/publisher.js b/src/client/components/pages/entities/publisher.js index e993f5e4a6..8f07e986c1 100644 --- a/src/client/components/pages/entities/publisher.js +++ b/src/client/components/pages/entities/publisher.js @@ -18,7 +18,6 @@ import * as bootstrap from 'react-bootstrap'; import * as entityHelper from '../../../helpers/entity'; - import EditionTable from './edition-table'; import EntityFooter from './footer'; import EntityImage from './image'; @@ -80,7 +79,7 @@ PublisherAttributes.propTypes = { }; -function PublisherDisplayPage({entity, identifierTypes}) { +function PublisherDisplayPage({entity, identifierTypes, user}) { const urlPrefix = getEntityUrl(entity); return (
@@ -110,8 +109,10 @@ function PublisherDisplayPage({entity, identifierTypes}) {
); @@ -119,7 +120,8 @@ function PublisherDisplayPage({entity, identifierTypes}) { PublisherDisplayPage.displayName = 'PublisherDisplayPage'; PublisherDisplayPage.propTypes = { entity: PropTypes.object.isRequired, - identifierTypes: PropTypes.array + identifierTypes: PropTypes.array, + user: PropTypes.object.isRequired }; PublisherDisplayPage.defaultProps = { identifierTypes: [] diff --git a/src/client/components/pages/entities/work.js b/src/client/components/pages/entities/work.js index 16c055ace6..5b99b1e424 100644 --- a/src/client/components/pages/entities/work.js +++ b/src/client/components/pages/entities/work.js @@ -71,7 +71,7 @@ WorkAttributes.propTypes = { }; -function WorkDisplayPage({entity, identifierTypes}) { +function WorkDisplayPage({entity, identifierTypes, user}) { // relationshipTypeId = 10 refers the relation ( is contained by ) const relationshipTypeId = 10; const editionsContainWork = getRelationshipSourceByTypeId(entity, relationshipTypeId); @@ -107,8 +107,10 @@ function WorkDisplayPage({entity, identifierTypes}) {
); @@ -116,7 +118,8 @@ function WorkDisplayPage({entity, identifierTypes}) { WorkDisplayPage.displayName = 'WorkDisplayPage'; WorkDisplayPage.propTypes = { entity: PropTypes.object.isRequired, - identifierTypes: PropTypes.array + identifierTypes: PropTypes.array, + user: PropTypes.object.isRequired }; WorkDisplayPage.defaultProps = { identifierTypes: [] diff --git a/src/client/components/pages/parts/add-to-collection-modal.js b/src/client/components/pages/parts/add-to-collection-modal.js new file mode 100644 index 0000000000..a8e3d04305 --- /dev/null +++ b/src/client/components/pages/parts/add-to-collection-modal.js @@ -0,0 +1,317 @@ +import * as bootstrap from 'react-bootstrap'; +import CustomInput from '../../../input'; +import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; +import PropTypes from 'prop-types'; +import React from 'react'; +import ReactSelect from 'react-select'; +import SelectWrapper from '../../input/select-wrapper'; +import _ from 'lodash'; +import request from 'superagent'; + + +const {Alert, Col, Button, Modal} = bootstrap; + +class AddToCollectionModal extends React.Component { + constructor(props) { + super(props); + this.state = { + collectionsAvailable: [], + message: { + text: null, + type: null + }, + selectedCollections: [], + showCollectionForm: false + }; + + this.getCollections = this.getCollections.bind(this); + this.handleAddToCollection = this.handleAddToCollection.bind(this); + this.toggleRow = this.toggleRow.bind(this); + this.handleShowCollectionForm = this.handleShowCollectionForm.bind(this); + this.handleShowAllCollections = this.handleShowAllCollections.bind(this); + this.handleAddToNewCollection = this.handleAddToNewCollection.bind(this); + this.isValid = this.isValid.bind(this); + } + + async componentDidMount() { + const collections = await this.getCollections(); + // eslint-disable-next-line react/no-did-mount-set-state + this.setState({collectionsAvailable: collections}); + } + + toggleRow(collectionId) { + const oldSelected = this.state.selectedCollections; + let newSelected; + if (oldSelected.find(selectedId => selectedId === collectionId)) { + newSelected = oldSelected.filter(selectedId => selectedId !== collectionId); + } + else { + newSelected = [...oldSelected, collectionId]; + } + this.setState({ + selectedCollections: newSelected + }); + } + + async getCollections() { + try { + // Get all collections of the user (unlikely that a user will have more than 10000 collections + const req = await request.get(`/editor/${this.props.userId}/collections/collections?type=${this.props.entityType}&size=10000`); + const collections = req.body; + return collections; + } + catch (err) { + return this.setState({ + message: { + text: 'Sorry, we could not fetch your collections ', + type: 'danger' + } + }); + } + } + + async handleAddToCollection() { + const {bbids} = this.props; + const {selectedCollections} = this.state; + if (selectedCollections.length) { + try { + const promiseArray = []; + selectedCollections.forEach((collectionId) => { + const submissionURL = `/collection/${collectionId}/add`; + promiseArray.push( + request.post(submissionURL) + .send({bbids}) + ); + }); + await Promise.all(promiseArray); + this.setState({selectedCollections: []}, () => { + this.props.closeModalAndShowMessage({ + text: `Successfully added to selected collection${selectedCollections.length > 1 ? 's' : ''}`, + type: 'success' + }); + }); + } + catch (err) { + this.setState({ + message: { + text: 'Something went wrong! Please try again later', + type: 'danger' + } + }); + } + } + else { + this.setState({ + message: { + text: 'No collection selected', + type: 'danger' + } + }); + } + } + + handleShowCollectionForm() { + this.setState({message: {}, showCollectionForm: true}); + } + + handleShowAllCollections() { + this.setState({message: {}, showCollectionForm: false}); + } + + handleAddToNewCollection(evt) { + evt.preventDefault(); + + if (!this.isValid()) { + this.setState({ + message: { + text: 'The form is incomplete. Please fill in a name and privacy option before continuing.', + type: 'danger' + } + }); + return; + } + + const description = this.description.getValue(); + const name = this.name.getValue(); + const privacy = this.privacy.getValue(); + const {entityType} = this.props; + + const data = { + description, + entityType, + name, + privacy + }; + const {bbids} = this.props; + request.post('/collection/create/handler') + .send(data) + .then((res) => { + request.post(`/collection/${res.body.id}/add`) + .send({bbids}).then(() => { + this.setState({selectedCollections: []}, () => { + this.props.closeModalAndShowMessage({ + text: `Successfully added to your new collection: ${name}`, + type: 'success' + }); + }); + }); + }, (error) => { + this.setState({ + message: { + text: 'Something went wrong! Please try again later', + type: 'danger' + } + }); + }); + } + + isValid() { + return _.trim(this.name.getValue()).length && this.privacy.getValue(); + } + + /* eslint-disable react/jsx-no-bind */ + render() { + let messageComponent = null; + if (this.state.message.text) { + messageComponent = ( +
+ {this.state.message.text} +
+ ); + } + + let existingCollections; + if (this.state.collectionsAvailable.length) { + existingCollections = ( +
+

+ Select the collection in which you want to add this entity or create a new collection +

+
+ { + this.state.collectionsAvailable.map((collection) => (( +
+ selectedId === collection.id)} + id={collection.id} + type="checkbox" + onChange={() => this.toggleRow(collection.id)} + /> + +
+ ))) + } +
+
+ ); + } + else { + existingCollections = ( +
+ Oops, looks like you do not yet have any collection of {this.props.entityType}s . + Click on the button below to create a new collection +
+ ); + } + + const privacyOptions = ['Private', 'Public'].map((option) => ({ + name: option + })); + const collectionForm = ( +
+ +
+ this.name = ref} + type="text" + /> + this.description = ref} + type="textarea" + /> + this.privacy = ref} + /> + + +
+ ); + + return ( + + + + {this.state.showCollectionForm ? 'Create' : 'Select'} Collection + + + + { + this.state.showCollectionForm ? collectionForm : existingCollections + } + {messageComponent} + + + { + this.state.showCollectionForm ? + : + + } + { + this.state.showCollectionForm ? + : + + } + + + + ); + } +} + + +AddToCollectionModal.displayName = 'AddToCollectionModal'; +AddToCollectionModal.propTypes = { + bbids: PropTypes.array.isRequired, + closeModalAndShowMessage: PropTypes.func.isRequired, + entityType: PropTypes.string.isRequired, + handleCloseModal: PropTypes.func.isRequired, + show: PropTypes.bool.isRequired, + userId: PropTypes.number.isRequired +}; + +export default AddToCollectionModal; diff --git a/src/client/components/pages/parts/collections-table.js b/src/client/components/pages/parts/collections-table.js index 28fd0001db..5ff99dcd53 100644 --- a/src/client/components/pages/parts/collections-table.js +++ b/src/client/components/pages/parts/collections-table.js @@ -64,7 +64,7 @@ class CollectionsTable extends React.Component { ))} All Types diff --git a/src/client/helpers/props.js b/src/client/helpers/props.js index 3088243f16..884a72ed7f 100644 --- a/src/client/helpers/props.js +++ b/src/client/helpers/props.js @@ -51,6 +51,7 @@ export function extractEntityProps(props) { return { alert: props.alert, entity: props.entity, - identifierTypes: props.identifierTypes + identifierTypes: props.identifierTypes, + user: props.user }; } diff --git a/src/client/stylesheets/style.less b/src/client/stylesheets/style.less index d7353eeed2..dcb52aa519 100644 --- a/src/client/stylesheets/style.less +++ b/src/client/stylesheets/style.less @@ -478,3 +478,13 @@ hr.wide { .badge.new { background-color: @green; } + +.label-checkbox { + padding: 0 .5em; + vertical-align: text-top; +} + +.addToCollectionModal-body { + margin-right: 1em; + margin-left: 1em; +} diff --git a/src/server/helpers/auth.js b/src/server/helpers/auth.js index 9783a4712b..5ad2b6db56 100644 --- a/src/server/helpers/auth.js +++ b/src/server/helpers/auth.js @@ -147,3 +147,15 @@ export function isCollectionOwner(req, res, next) { 'You do not have permission to edit/delete this collection', req ); } + +export function isCollectionOwnerOrCollaborator(req, res, next) { + const {collection} = res.locals; + if (req.user.id === collection.ownerId || + collection.collaborators.filter(collaborator => collaborator.id === req.user.id).length) { + return next(); + } + + throw new error.PermissionDeniedError( + 'You do not have permission to edit this collection', req + ); +} diff --git a/src/server/routes/collection.js b/src/server/routes/collection.js index e08db70603..f3b40db421 100644 --- a/src/server/routes/collection.js +++ b/src/server/routes/collection.js @@ -71,7 +71,6 @@ router.post('/:collectionId/delete/handler', auth.isAuthenticatedForHandler, aut router.get('/:collectionId/edit', auth.isAuthenticated, auth.isCollectionOwner, (req, res) => { const {collection} = res.locals; - const props = generateProps(req, res, { collection }); @@ -99,4 +98,34 @@ router.get('/:collectionId', (req, res) => { res.status(200).send(collection); }); +/* eslint-disable no-await-in-loop */ +router.post('/:collectionId/add', auth.isAuthenticated, auth.isCollectionOwnerOrCollaborator, async (req, res) => { + const {bbids} = req.body; + const {collection} = res.locals; + try { + const {UserCollectionItem} = req.app.locals.orm; + for (const bbid of bbids) { + // because of the unique constraint, we can't add duplicate entities to a collection + // using try catch to make sure code doesn't break if user accidentally adds duplicate entity + try { + await new UserCollectionItem({ + bbid, + collectionId: collection.id + }).save(null, {method: 'insert'}); + } + catch (err) { + // throw error if it's not due to unique constraint + if (err.constraint !== 'user_collection_item_pkey') { + throw err; + } + } + } + res.status(200).send(); + } + catch (err) { + log.debug(err); + res.status(500).send(); + } +}); + export default router;