Skip to content

Commit

Permalink
Merge pull request #191 from guardian/pm-drag-drop-copy
Browse files Browse the repository at this point in the history
Support copying files between workspaces
  • Loading branch information
philmcmahon authored Jan 9, 2024
2 parents 6380cda + 74288be commit f58096c
Show file tree
Hide file tree
Showing 12 changed files with 200 additions and 25 deletions.
55 changes: 49 additions & 6 deletions backend/app/controllers/api/Workspaces.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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._
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(())
Expand All @@ -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")

Expand Down
6 changes: 4 additions & 2 deletions backend/app/services/annotations/Annotations.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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]
Expand All @@ -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)
}
12 changes: 11 additions & 1 deletion backend/app/services/annotations/Neo4jAnnotations.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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}

Expand Down Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions backend/app/utils/controller/FailureToResultMapper.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions backend/conf/routes
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions backend/test/test/TestAnnotations.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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(""))
Expand Down
2 changes: 1 addition & 1 deletion backend/test/test/integration/Helpers.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down
2 changes: 2 additions & 0 deletions common/src/main/scala/utils/attempt/Failure.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
43 changes: 43 additions & 0 deletions frontend/src/js/actions/workspaces/copyItem.ts
Original file line number Diff line number Diff line change
@@ -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<void, GiantState, null, WorkspacesAction | AppAction> {
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<void, GiantState, null, WorkspacesAction | AppAction> {
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,
};
}
30 changes: 30 additions & 0 deletions frontend/src/js/components/workspace/CopyOrMoveModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import React from 'react';

type Props = {
destinationWorkspaceName: string
onSubmit: (action: "copy" | "move") => void
}
export const CopyOrMoveModal = ({destinationWorkspaceName, onSubmit}: Props) => {

return (
<form className='form' >
<h2>Copy or move items</h2>
Do you want to copy or move the selected items to the workspace '{destinationWorkspaceName}'?

<div className='modal-action__buttons'>
<button
className='btn'
onClick={() => onSubmit("move")}
type='button'>Move</button>


<button
className='btn'
onClick={() => onSubmit("copy")}
type='button'>Copy</button>
</div>
</form>
);


}
58 changes: 43 additions & 15 deletions frontend/src/js/components/workspace/WorkspacesSidebarItem.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
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';
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,
Expand All @@ -17,19 +20,43 @@ type PropTypes = ReturnType<typeof mapDispatchToProps>
& ReturnType<typeof mapStateToProps>
& PropsFromParent

const WorkspacesSidebarItem: FC<PropTypes> = ({selectedEntries, moveItems, selectedWorkspaceId, linkedToWorkspaceId, linkedToWorkspaceName}) => {
return <SidebarSearchLink
onDrop={(e: React.DragEvent) => {
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}`}
>
<div className='sidebar__item__text'>{linkedToWorkspaceName}</div>
</SidebarSearchLink>

const WorkspacesSidebarItem: FC<PropTypes> = ({selectedEntries, moveItems, copyItems, selectedWorkspaceId, linkedToWorkspaceId, linkedToWorkspaceName}) => {
const [copyOrMoveModalOpen, setCopyOrMoveModalOpen] = useState<boolean>(false)
const [entryIds, setEntryIds] = useState<string[]>([])
const [invalidDestinationModalOpen, setInvalidDestinationModalOpen] = useState<boolean>(false)
return <>
<SidebarSearchLink
onDrop={(e: React.DragEvent) => {
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}`}
>
<div className='sidebar__item__text'>{linkedToWorkspaceName}</div>
</SidebarSearchLink>
<Modal isOpen={invalidDestinationModalOpen} dismiss={() => setInvalidDestinationModalOpen(false)}>
<form className='form' >
Sorry, you cannot copy or move items to a workspace they are already in
</form>
</Modal>
<Modal isOpen={copyOrMoveModalOpen} dismiss={() => setCopyOrMoveModalOpen(false)}>
<CopyOrMoveModal destinationWorkspaceName={linkedToWorkspaceName} onSubmit={(action: "copy" | "move") => {
const actionFn = action === "copy" ? copyItems : moveItems
actionFn(selectedWorkspaceId, entryIds, linkedToWorkspaceId)
setCopyOrMoveModalOpen(false)
}} />
</Modal>
</>
}

function mapStateToProps(state: GiantState) {
Expand All @@ -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)
}
}

Expand Down
11 changes: 11 additions & 0 deletions frontend/src/js/services/WorkspaceApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'}),
Expand Down

0 comments on commit f58096c

Please sign in to comment.