diff --git a/backend/app/controllers/api/Workspaces.scala b/backend/app/controllers/api/Workspaces.scala index 393b186f..afa920cb 100644 --- a/backend/app/controllers/api/Workspaces.scala +++ b/backend/app/controllers/api/Workspaces.scala @@ -2,8 +2,8 @@ package controllers.api import commands.DeleteResource import model.Uri -import model.annotations.Workspace -import model.frontend.TreeEntry +import model.annotations.{Workspace, WorkspaceEntry, WorkspaceLeaf} +import model.frontend.{TreeEntry, TreeLeaf, TreeNode} import net.logstash.logback.marker.LogstashMarker import net.logstash.logback.marker.Markers.append import play.api.libs.json._ @@ -55,9 +55,9 @@ object RenameItemData { implicit val format = Json.format[RenameItemData] } -case class MoveItemData(newParentId: Option[String], newWorkspaceId: Option[String]) -object MoveItemData { - implicit val format = Json.format[MoveItemData] +case class MoveCopyDestination(newParentId: Option[String], newWorkspaceId: Option[String]) +object MoveCopyDestination { + implicit val format = Json.format[MoveCopyDestination] } class Workspaces(override val controllerComponents: AuthControllerComponents, annotation: Annotations, index: Index, manifest: Manifest, @@ -201,6 +201,33 @@ class Workspaces(override val controllerComponents: AuthControllerComponents, an } } + private def copyTree(workspaceId: String, destinationParentId: String, tree: TreeEntry[WorkspaceEntry], user: String): Attempt[List[String]] = { + val newId = UUID.randomUUID().toString + tree match { + case TreeLeaf(_, name, data, _) => + // a TreeLeaf won't have any children, so just insert the item at the destination location, and return it's new ID + data match { + case WorkspaceLeaf(_, _, _, _, uri, mimeType, size) => + val addItemData = AddItemData(name, destinationParentId, "file", Some("document"), AddItemParameters(Some(uri), size, Some(mimeType))) + insertItem(user, workspaceId, newId, addItemData).map(i => List(i)) + case _ => Attempt.Left(WorkspaceCopyFailure("Unexpected data type of TreeLeaf")) + } + + case TreeNode(_, name, _, children) => + // TreeNodes are folders. We need to create the folder in the new destination, and then recurse on every child item + + // create the folder in the destination location + val addItemData = AddItemData(name, destinationParentId, "folder", None, AddItemParameters(None, None, None)) + val newFolderIdAttempt = insertItem(user, workspaceId, newId, addItemData) + // for every child, recurse, setting the newly created folder as the destination + val newChildIds = newFolderIdAttempt.flatMap{ newFolderId => + Attempt.traverse(children)(child => copyTree(workspaceId, newFolderId, child, user)) + } + // return ids of all child nodes combined with the id of the new folder + newChildIds.map(ids => newId +: ids.flatten ) + } + } + def addItemToWorkspace(workspaceId: String) = ApiAction.attempt(parse.json) { req => for { data <- req.body.validate[AddItemData].toAttempt @@ -243,7 +270,7 @@ class Workspaces(override val controllerComponents: AuthControllerComponents, an def moveItem(workspaceId: String, itemId: String) = ApiAction.attempt(parse.json) { req => for { - data <- req.body.validate[MoveItemData].toAttempt + data <- req.body.validate[MoveCopyDestination].toAttempt _ = logAction(req.user, workspaceId, s"Move workspace item. Node ID: $itemId. Data: $data") _ <- if (data.newParentId.contains(itemId)) Attempt.Left(ClientFailure("Cannot move a workspace item to be under itself")) else Attempt.Right(()) @@ -256,6 +283,22 @@ class Workspaces(override val controllerComponents: AuthControllerComponents, an } } + def copyItem(workspaceId: String, itemId: String) = ApiAction.attempt(parse.json) { req => + for { + data <- req.body.validate[MoveCopyDestination].toAttempt + _ = logAction(req.user, workspaceId, s"Copy workspace item. Node ID: $itemId. Data: $data") + + _ <- if (data.newParentId.contains(itemId)) Attempt.Left(ClientFailure("Cannot copy a workspace item to the same location")) else Attempt.Right(()) + copyDestination <-annotation.getCopyDestination(req.user.username, workspaceId, data.newWorkspaceId, data.newParentId) + workspaceContents <- annotation.getWorkspaceContents(req.user.username, workspaceId) + _ <- TreeEntry.findNodeById(workspaceContents, itemId) + .map(entry => copyTree(copyDestination.workspaceId, copyDestination.parentId, entry, req.user.username)).getOrElse(Attempt.Left(ClientFailure("Must supply at least one of newWorkspaceId or newParentId"))) + } yield { + NoContent + } + } + + def removeItem(workspaceId: String, itemId: String) = ApiAction.attempt { req => logAction(req.user, workspaceId, s"Rename workspace item. Node ID: $itemId") diff --git a/backend/app/services/annotations/Annotations.scala b/backend/app/services/annotations/Annotations.scala index 05bdf8cb..cbcd4fec 100644 --- a/backend/app/services/annotations/Annotations.scala +++ b/backend/app/services/annotations/Annotations.scala @@ -3,7 +3,7 @@ package services.annotations import model.Uri import model.annotations._ import model.frontend.TreeEntry -import services.annotations.Annotations.{AffectedResource, DeleteItemResult, MoveItemResult} +import services.annotations.Annotations.{AffectedResource, CopyDestination, DeleteItemResult, MoveItemResult} import utils.attempt.{Attempt, Failure} import org.neo4j.driver.v1.Value @@ -25,7 +25,7 @@ trait Annotations { def renameWorkspaceItem(currentUser: String, workspaceId: String, itemId: String, name: String): Attempt[Unit] def moveWorkspaceItem(currentUser: String, workspaceId: String, itemId: String, newWorkspaceId: Option[String], newParentId: Option[String]): Attempt[MoveItemResult] def deleteWorkspaceItem(currentUser: String, workspaceId: String, itemId: String): Attempt[DeleteItemResult] - + def getCopyDestination(user: String, workspaceId: String, newWorkspaceId: Option[String], newParentId: Option[String]): Attempt[CopyDestination] def postComment(currentUser: String, uri: Uri, text: String, anchor: Option[CommentAnchor]): Attempt[Unit] def getComments(uri: Uri): Attempt[List[Comment]] def deleteComment(currentUser: String, commentId: String): Attempt[Unit] @@ -48,4 +48,6 @@ object Annotations { case class DeleteItemResult(resourcesRemoved: List[AffectedResource]) case class MoveItemResult(resourcesMoved: List[AffectedResource]) + + case class CopyDestination(workspaceId: String, parentId: String) } diff --git a/backend/app/services/annotations/Neo4jAnnotations.scala b/backend/app/services/annotations/Neo4jAnnotations.scala index 94ba3376..b2a56e3b 100644 --- a/backend/app/services/annotations/Neo4jAnnotations.scala +++ b/backend/app/services/annotations/Neo4jAnnotations.scala @@ -11,7 +11,7 @@ import org.neo4j.driver.v1.{Driver, Record, Value} import org.neo4j.driver.v1.Values.parameters import play.api.libs.json.Json import services.Neo4jQueryLoggingConfig -import services.annotations.Annotations.{AffectedResource, DeleteItemResult, MoveItemResult} +import services.annotations.Annotations.{AffectedResource, CopyDestination, DeleteItemResult, MoveItemResult} import utils._ import utils.attempt.{Attempt, ClientFailure, Failure, IllegalStateFailure, NotFoundFailure} @@ -386,6 +386,16 @@ class Neo4jAnnotations(driver: Driver, executionContext: ExecutionContext, query } } + def getCopyDestination(user: String, workspaceId: String, newWorkspaceId: Option[String], newParentId: Option[String]): Attempt[CopyDestination] = { + (newWorkspaceId, newParentId) match { + case (None, None) => Attempt.Left(ClientFailure("Must supply at least one of newWorkspaceId or newParentId")) + case (Some(newWorkspaceId), None) => + getWorkspaceRootNodeId(user, newWorkspaceId).map(id => CopyDestination(newWorkspaceId, id)) + case (None, Some(newParentId)) => Attempt.Right(CopyDestination(workspaceId, newParentId)) + case (Some(newWorkspaceId), Some(newParentId)) => Attempt.Right(CopyDestination(newWorkspaceId, newParentId)) + } + } + private def moveToRootOfNewWorkspace(currentUser: String, workspaceId: String, itemId: String, newWorkspaceId: String): Attempt[MoveItemResult] = { for { rootNodeId <- getWorkspaceRootNodeId(currentUser, newWorkspaceId) diff --git a/backend/app/utils/controller/FailureToResultMapper.scala b/backend/app/utils/controller/FailureToResultMapper.scala index 355ecb00..c0fcede6 100644 --- a/backend/app/utils/controller/FailureToResultMapper.scala +++ b/backend/app/utils/controller/FailureToResultMapper.scala @@ -111,6 +111,9 @@ object FailureToResultMapper extends Logging { case DeleteFailure(msg) => logUserAndMessage(user, s"Delete failed: ${msg}") Results.InternalServerError(msg) + case WorkspaceCopyFailure(msg) => + logUserAndMessage(user, s"Workspace copy failed: ${msg}") + Results.InternalServerError(msg) case DeleteNotAllowed(msg) => logUserAndMessage(user, s"Deletion is refused: ${msg}") Results.Forbidden(msg) diff --git a/backend/conf/routes b/backend/conf/routes index 17e6728e..bcd0a250 100644 --- a/backend/conf/routes +++ b/backend/conf/routes @@ -65,6 +65,7 @@ POST /api/workspaces/:workspaceId/reprocess cont POST /api/workspaces/:workspaceId/nodes controllers.api.Workspaces.addItemToWorkspace(workspaceId: String) PUT /api/workspaces/:workspaceId/nodes/:itemId/name controllers.api.Workspaces.renameItem(workspaceId: String, itemId: String) PUT /api/workspaces/:workspaceId/nodes/:itemId/parent controllers.api.Workspaces.moveItem(workspaceId: String, itemId: String) +POST /api/workspaces/:workspaceId/nodes/:itemId/copy controllers.api.Workspaces.copyItem(workspaceId: String, itemId: String) DELETE /api/workspaces/:workspaceId/nodes/:itemId controllers.api.Workspaces.removeItem(workspaceId: String, itemId: String) POST /api/workspaces/:workspaceId/nodes/delete/:blobUri controllers.api.Workspaces.deleteBlob(workspaceId: String, blobUri: String) DELETE /api/workspaces/:workspaceId controllers.api.Workspaces.deleteWorkspace(workspaceId: String) diff --git a/backend/test/test/TestAnnotations.scala b/backend/test/test/TestAnnotations.scala index 2f31e9ae..784f94c3 100644 --- a/backend/test/test/TestAnnotations.scala +++ b/backend/test/test/TestAnnotations.scala @@ -4,6 +4,7 @@ import model.Uri import model.annotations.{Comment, CommentAnchor, WorkspaceEntry, WorkspaceMetadata} import model.frontend.TreeEntry import services.annotations.Annotations +import services.annotations.Annotations.CopyDestination import utils.attempt.{Attempt, Failure, UnsupportedOperationFailure} class TestAnnotations(usersToWorkspaces: Map[String, List[String]] = Map.empty) extends Annotations { @@ -26,6 +27,7 @@ class TestAnnotations(usersToWorkspaces: Map[String, List[String]] = Map.empty) override def renameWorkspaceItem(currentUser: String, workspaceId: String, itemId: String, name: String): Attempt[Unit] = Attempt.Left(UnsupportedOperationFailure("")) override def moveWorkspaceItem(currentUser: String, workspaceId: String, itemId: String, newWorkspaceId: Option[String], newParentId: Option[String]): Attempt[Annotations.MoveItemResult] = Attempt.Left(UnsupportedOperationFailure("")) override def deleteWorkspaceItem(currentUser: String, workspaceId: String, itemId: String): Attempt[Annotations.DeleteItemResult] = Attempt.Left(UnsupportedOperationFailure("")) + override def getCopyDestination(user: String, workspaceId: String, newWorkspaceId: Option[String], newParentId: Option[String]): Attempt[CopyDestination] = Attempt.Left(UnsupportedOperationFailure("")) override def postComment(currentUser: String, uri: Uri, text: String, anchor: Option[CommentAnchor]): Attempt[Unit] = Attempt.Left(UnsupportedOperationFailure("")) override def getComments(uri: Uri): Attempt[List[Comment]] = Attempt.Left(UnsupportedOperationFailure("")) override def deleteComment(currentUser: String, commentId: String): Attempt[Unit] = Attempt.Left(UnsupportedOperationFailure("")) diff --git a/backend/test/test/integration/Helpers.scala b/backend/test/test/integration/Helpers.scala index 8a7dd28d..ebc5c126 100644 --- a/backend/test/test/integration/Helpers.scala +++ b/backend/test/test/integration/Helpers.scala @@ -334,7 +334,7 @@ object Helpers extends Matchers with Logging with OptionValues with Inside { def moveWorkspaceItem(workspaceId: String, itemId: String, newParentId: Option[String] = None, newWorkspaceId: Option[String] = None)(implicit controllers: Controllers, timeout: Timeout): Future[Result] = controllers.workspace.moveItem(workspaceId, itemId) .apply(FakeRequest().withBody(Json.toJson( - MoveItemData( + MoveCopyDestination( newParentId = newParentId, newWorkspaceId = newWorkspaceId ) diff --git a/common/src/main/scala/utils/attempt/Failure.scala b/common/src/main/scala/utils/attempt/Failure.scala index 1a9dbad9..1c9130e4 100644 --- a/common/src/main/scala/utils/attempt/Failure.scala +++ b/common/src/main/scala/utils/attempt/Failure.scala @@ -130,4 +130,6 @@ case class ContentTooLongFailure(msg: String) extends Failure case class DeleteFailure(msg: String) extends Failure +case class WorkspaceCopyFailure(msg: String) extends Failure + case class DeleteNotAllowed(msg: String) extends Failure \ No newline at end of file diff --git a/frontend/src/js/actions/workspaces/copyItem.ts b/frontend/src/js/actions/workspaces/copyItem.ts new file mode 100644 index 00000000..99a83552 --- /dev/null +++ b/frontend/src/js/actions/workspaces/copyItem.ts @@ -0,0 +1,43 @@ +import { copyItem as copyItemApi } from '../../services/WorkspaceApi'; +import {ThunkAction} from "redux-thunk"; +import {GiantState} from "../../types/redux/GiantState"; +import {AppAction, AppActionType, WorkspacesAction} from "../../types/redux/GiantActions"; +import {getWorkspace} from "./getWorkspace"; + +export function copyItems( + workspaceId: string, + itemIds: string[], + newWorkspaceId?: string, + newParentId?: string // Note: not currently used but could be useful for future 'copy to folder' functionality +): ThunkAction { + return dispatch => { + for (const itemId of itemIds) { + if (itemId !== newParentId) { + dispatch(copyItem(workspaceId, itemId, newWorkspaceId, newParentId)); + } + } + }; +} + +export function copyItem( + workspaceId: string, + itemId: string, + newWorkspaceId?: string, + newParentId?: string // Note: not currently used but could be useful for future 'copy to folder' functionality +): ThunkAction { + return dispatch => { + return copyItemApi(workspaceId, itemId, newWorkspaceId, newParentId) + .then(() => { + dispatch(getWorkspace(workspaceId)); + }) + .catch(error => dispatch(() => errorCopyingItem(error))); + }; +} + +function errorCopyingItem(error: Error): AppAction { + return { + type: AppActionType.APP_SHOW_ERROR, + message: 'Failed to copy item', + error: error, + }; +} diff --git a/frontend/src/js/components/workspace/CopyOrMoveModal.tsx b/frontend/src/js/components/workspace/CopyOrMoveModal.tsx new file mode 100644 index 00000000..fd5b5541 --- /dev/null +++ b/frontend/src/js/components/workspace/CopyOrMoveModal.tsx @@ -0,0 +1,30 @@ +import React from 'react'; + +type Props = { + destinationWorkspaceName: string + onSubmit: (action: "copy" | "move") => void +} +export const CopyOrMoveModal = ({destinationWorkspaceName, onSubmit}: Props) => { + + return ( +
+

Copy or move items

+ Do you want to copy or move the selected items to the workspace '{destinationWorkspaceName}'? + +
+ + + + +
+
+ ); + + +} diff --git a/frontend/src/js/components/workspace/WorkspacesSidebarItem.tsx b/frontend/src/js/components/workspace/WorkspacesSidebarItem.tsx index e21fee74..6c6079ce 100644 --- a/frontend/src/js/components/workspace/WorkspacesSidebarItem.tsx +++ b/frontend/src/js/components/workspace/WorkspacesSidebarItem.tsx @@ -1,4 +1,4 @@ -import React, { FC } from 'react'; +import React, {FC, useState} from 'react'; import SidebarSearchLink from '../UtilComponents/SidebarSearchLink'; import { moveItems } from '../../actions/workspaces/moveItem'; import { GiantState } from '../../types/redux/GiantState'; @@ -6,6 +6,9 @@ import { bindActionCreators } from 'redux'; import { GiantDispatch } from '../../types/redux/GiantDispatch'; import { connect } from 'react-redux'; import { getIdsOfEntriesToMove } from '../../util/treeUtils'; +import {copyItems} from "../../actions/workspaces/copyItem"; +import Modal from "../UtilComponents/Modal"; +import {CopyOrMoveModal} from "./CopyOrMoveModal"; interface PropsFromParent { selectedWorkspaceId: string, @@ -17,19 +20,43 @@ type PropTypes = ReturnType & ReturnType & PropsFromParent -const WorkspacesSidebarItem: FC = ({selectedEntries, moveItems, selectedWorkspaceId, linkedToWorkspaceId, linkedToWorkspaceName}) => { - return { - const json = e.dataTransfer.getData('application/json'); - const {id: idOfDraggedEntry} = JSON.parse(json); - const entryIds = getIdsOfEntriesToMove(selectedEntries, idOfDraggedEntry); - moveItems(selectedWorkspaceId, entryIds, linkedToWorkspaceId); - }} - key={linkedToWorkspaceId} - to={`/workspaces/${linkedToWorkspaceId}`} - > -
{linkedToWorkspaceName}
-
+ +const WorkspacesSidebarItem: FC = ({selectedEntries, moveItems, copyItems, selectedWorkspaceId, linkedToWorkspaceId, linkedToWorkspaceName}) => { + const [copyOrMoveModalOpen, setCopyOrMoveModalOpen] = useState(false) + const [entryIds, setEntryIds] = useState([]) + const [invalidDestinationModalOpen, setInvalidDestinationModalOpen] = useState(false) + return <> + { + if (linkedToWorkspaceId === selectedWorkspaceId) { + setInvalidDestinationModalOpen(true) + return; + } + const json = e.dataTransfer.getData('application/json'); + const {id: idOfDraggedEntry} = JSON.parse(json); + const entryIds = getIdsOfEntriesToMove(selectedEntries, idOfDraggedEntry); + setEntryIds(entryIds) + // Ask user whether they want to move or copy or move the files + setCopyOrMoveModalOpen(true) + }} + key={linkedToWorkspaceId} + to={`/workspaces/${linkedToWorkspaceId}`} + > +
{linkedToWorkspaceName}
+
+ setInvalidDestinationModalOpen(false)}> +
+ Sorry, you cannot copy or move items to a workspace they are already in +
+
+ setCopyOrMoveModalOpen(false)}> + { + const actionFn = action === "copy" ? copyItems : moveItems + actionFn(selectedWorkspaceId, entryIds, linkedToWorkspaceId) + setCopyOrMoveModalOpen(false) + }} /> + + } function mapStateToProps(state: GiantState) { @@ -40,7 +67,8 @@ function mapStateToProps(state: GiantState) { function mapDispatchToProps(dispatch: GiantDispatch) { return { - moveItems: bindActionCreators(moveItems, dispatch) + moveItems: bindActionCreators(moveItems, dispatch), + copyItems: bindActionCreators(copyItems, dispatch) } } diff --git a/frontend/src/js/services/WorkspaceApi.ts b/frontend/src/js/services/WorkspaceApi.ts index e0a855ae..fa8b2f29 100644 --- a/frontend/src/js/services/WorkspaceApi.ts +++ b/frontend/src/js/services/WorkspaceApi.ts @@ -58,6 +58,17 @@ export function moveItem(workspaceId: string, itemId: string, newWorkspaceId?: s }); } +export function copyItem(workspaceId: string, itemId: string, newWorkspaceId?: string, newParentId?: string) { + return authFetch(`/api/workspaces/${workspaceId}/nodes/${itemId}/copy`, { + headers: new Headers({'Content-Type': 'application/json'}), + method: 'POST', + body: JSON.stringify({ + newWorkspaceId: newWorkspaceId, + newParentId: newParentId + }) + }); +} + export function renameItem(workspaceId: string, itemId: string, newName: string) { return authFetch(`/api/workspaces/${workspaceId}/nodes/${itemId}/name`, { headers: new Headers({'Content-Type': 'application/json'}),