diff --git a/cypress/e2e/boardFeatures.js b/cypress/e2e/boardFeatures.js index 1e3bf752e..6bb7ee6cd 100644 --- a/cypress/e2e/boardFeatures.js +++ b/cypress/e2e/boardFeatures.js @@ -5,6 +5,7 @@ import { randUser } from '../utils/index.js' const user = randUser() const recipient = randUser() +import { sampleBoard } from '../utils/sampleBoard' describe('Board', function() { @@ -58,3 +59,73 @@ describe('Board', function() { .should('be.visible') }) }) + +describe('Board cloning', function() { + before(function() { + cy.createUser(user) + }) + + it('Clones a board without cards', function() { + const boardName = 'Clone board original' + const board = sampleBoard(boardName) + cy.createExampleBoard({ user, board }).then((board) => { + const boardId = board.id + cy.visit(`/apps/deck/board/${boardId}`) + cy.get('.app-navigation__list .app-navigation-entry:contains("' + boardName + '")') + .parent() + .find('button[aria-label="Actions"]') + .click() + cy.get('button:contains("Clone board")') + .click() + + cy.get('.modal-container button:contains("Clone")') + .click() + + cy.get('.app-navigation__list .app-navigation-entry:contains("' + boardName + '")') + .should('be.visible') + + cy.get('.app-navigation__list .app-navigation-entry:contains("' + boardName + ' (copy)")') + .should('be.visible') + + cy.get('.board-title h2').contains(boardName + ' (copy)') + + cy.get('h3[aria-label="TestList"]') + .should('be.visible') + }) + }) + + it('Clones a board with cards', function() { + const boardName = 'Clone with cards' + const board = sampleBoard(boardName) + cy.createExampleBoard({ user, board }).then((board) => { + const boardId = board.id + cy.visit(`/apps/deck/board/${boardId}`) + cy.get('.app-navigation__list .app-navigation-entry:contains("' + boardName + '")') + .parent() + .find('button[aria-label="Actions"]') + .click() + cy.get('button:contains("Clone board")') + .click() + + cy.get('.checkbox-content__text:contains("Clone cards")') + .click() + + cy.get('.modal-container button:contains("Clone")') + .click() + + cy.get('.app-navigation__list .app-navigation-entry:contains("' + boardName + '")') + .should('be.visible') + + cy.get('.app-navigation__list .app-navigation-entry:contains("' + boardName + ' (copy)")') + .should('be.visible') + + cy.get('.board-title h2').contains(boardName + ' (copy)') + + cy.get('h3[aria-label="TestList"]') + .should('be.visible') + + cy.get('.card:contains("Hello world")') + .should('be.visible') + }) + }) +}) diff --git a/docs/API.md b/docs/API.md index eafab9e4a..6fb4ba540 100644 --- a/docs/API.md +++ b/docs/API.md @@ -451,6 +451,25 @@ A 403 response might be returned if the users ability to create new boards has b ##### 200 Success +### POST /boards/{boardId}/clone - Clone a board + +Creates a copy of the board. + +#### Request body + +| Parameter | Type | Description | +| --------- | ------ | ---------------------------------------------------- | +| withCards | Bool | Setting if the cards should be copied (Default: false) | +| withAssignments | Bool | Setting if the card assignments should be cloned (Default: false) | +| withLabels | Bool | Setting if the card labels should be cloned (Default: false) | +| withDueDate | Bool | Setting if the card due dates should be cloned (Default: false) | +| moveCardsToLeftStack | Bool | Setting if all cards should be moved to the most left column (useful for To-Do / Doing / Done boards) (Default: false) | +| restoreArchivedCards | Bool | Setting if the archived cards should be unarchived (Default: false) | + +#### Response + +##### 200 Success + ### DELETE /boards/{boardId}/acl/{aclId} - Delete an acl rule #### Response diff --git a/lib/Controller/BoardApiController.php b/lib/Controller/BoardApiController.php index b1cb65c25..574656af6 100644 --- a/lib/Controller/BoardApiController.php +++ b/lib/Controller/BoardApiController.php @@ -164,4 +164,13 @@ public function deleteAcl($aclId) { $acl = $this->boardService->deleteAcl($aclId); return new DataResponse($acl, HTTP::STATUS_OK); } + + /** + * @NoAdminRequired + */ + public function clone(int $boardId, bool $withCards = false, bool $withAssignments = false, bool $withLabels = false, bool $withDueDate = false, bool $moveCardsToLeftStack = false, bool $restoreArchivedCards = false): DataResponse { + return new DataResponse( + $this->boardService->clone($boardId, $this->userId, $withCards, $withAssignments, $withLabels, $withDueDate, $moveCardsToLeftStack, $restoreArchivedCards) + ); + } } diff --git a/lib/Controller/BoardController.php b/lib/Controller/BoardController.php index a8710f86a..e79ac276e 100644 --- a/lib/Controller/BoardController.php +++ b/lib/Controller/BoardController.php @@ -135,11 +135,11 @@ public function deleteAcl($aclId) { /** * @NoAdminRequired - * @param $boardId - * @return Board */ - public function clone($boardId) { - return $this->boardService->clone($boardId, $this->userId); + public function clone(int $boardId, bool $withCards = false, bool $withAssignments = false, bool $withLabels = false, bool $withDueDate = false, bool $moveCardsToLeftStack = false, bool $restoreArchivedCards = false): DataResponse { + return new DataResponse( + $this->boardService->clone($boardId, $this->userId, $withCards, $withAssignments, $withLabels, $withDueDate, $moveCardsToLeftStack, $restoreArchivedCards) + ); } /** diff --git a/lib/Db/Label.php b/lib/Db/Label.php index 170cd4939..f5d737dad 100644 --- a/lib/Db/Label.php +++ b/lib/Db/Label.php @@ -7,6 +7,9 @@ namespace OCA\Deck\Db; +/** + * @method getTitle(): string + */ class Label extends RelationalEntity { protected $title; protected $color; diff --git a/lib/Service/AssignmentService.php b/lib/Service/AssignmentService.php index a344bdc8a..e6dc0b9c2 100644 --- a/lib/Service/AssignmentService.php +++ b/lib/Service/AssignmentService.php @@ -88,8 +88,6 @@ public function __construct( $this->changeHelper = $changeHelper; $this->activityManager = $activityManager; $this->eventDispatcher = $eventDispatcher; - - $this->assignmentServiceValidator->check(compact('userId')); $this->currentUser = $userId; } diff --git a/lib/Service/BoardService.php b/lib/Service/BoardService.php index c1fc92b93..d8547fc28 100644 --- a/lib/Service/BoardService.php +++ b/lib/Service/BoardService.php @@ -16,6 +16,7 @@ use OCA\Deck\Db\AssignmentMapper; use OCA\Deck\Db\Board; use OCA\Deck\Db\BoardMapper; +use OCA\Deck\Db\Card; use OCA\Deck\Db\CardMapper; use OCA\Deck\Db\ChangeHelper; use OCA\Deck\Db\IPermissionMapper; @@ -29,6 +30,7 @@ use OCA\Deck\Event\AclDeletedEvent; use OCA\Deck\Event\AclUpdatedEvent; use OCA\Deck\Event\BoardUpdatedEvent; +use OCA\Deck\Event\CardCreatedEvent; use OCA\Deck\NoPermissionException; use OCA\Deck\Notification\NotificationHelper; use OCA\Deck\Validators\BoardServiceValidator; @@ -38,80 +40,37 @@ use OCP\EventDispatcher\IEventDispatcher; use OCP\IConfig; use OCP\IDBConnection; -use OCP\IGroupManager; use OCP\IL10N; use OCP\IURLGenerator; -use OCP\IUserManager; use OCP\Server; use Psr\Container\ContainerExceptionInterface; use Psr\Container\NotFoundExceptionInterface; class BoardService { - private BoardMapper $boardMapper; - private StackMapper $stackMapper; - private LabelMapper $labelMapper; - private AclMapper $aclMapper; - private IConfig $config; - private IL10N $l10n; - private PermissionService $permissionService; - private NotificationHelper $notificationHelper; - private AssignmentMapper $assignedUsersMapper; - private IUserManager $userManager; - private IGroupManager $groupManager; - private ?string $userId; - private ActivityManager $activityManager; - private IEventDispatcher $eventDispatcher; - private ChangeHelper $changeHelper; - private CardMapper $cardMapper; private ?array $boardsCacheFull = null; private ?array $boardsCachePartial = null; - private IURLGenerator $urlGenerator; - private IDBConnection $connection; - private BoardServiceValidator $boardServiceValidator; - private SessionMapper $sessionMapper; public function __construct( - BoardMapper $boardMapper, - StackMapper $stackMapper, - CardMapper $cardMapper, - IConfig $config, - IL10N $l10n, - LabelMapper $labelMapper, - AclMapper $aclMapper, - PermissionService $permissionService, - NotificationHelper $notificationHelper, - AssignmentMapper $assignedUsersMapper, - IUserManager $userManager, - IGroupManager $groupManager, - ActivityManager $activityManager, - IEventDispatcher $eventDispatcher, - ChangeHelper $changeHelper, - IURLGenerator $urlGenerator, - IDBConnection $connection, - BoardServiceValidator $boardServiceValidator, - SessionMapper $sessionMapper, - ?string $userId, + private BoardMapper $boardMapper, + private StackMapper $stackMapper, + private CardMapper $cardMapper, + private IConfig $config, + private IL10N $l10n, + private LabelMapper $labelMapper, + private AclMapper $aclMapper, + private PermissionService $permissionService, + private AssignmentService $assignmentService, + private NotificationHelper $notificationHelper, + private AssignmentMapper $assignedUsersMapper, + private ActivityManager $activityManager, + private IEventDispatcher $eventDispatcher, + private ChangeHelper $changeHelper, + private IURLGenerator $urlGenerator, + private IDBConnection $connection, + private BoardServiceValidator $boardServiceValidator, + private SessionMapper $sessionMapper, + private ?string $userId, ) { - $this->boardMapper = $boardMapper; - $this->stackMapper = $stackMapper; - $this->cardMapper = $cardMapper; - $this->labelMapper = $labelMapper; - $this->config = $config; - $this->aclMapper = $aclMapper; - $this->l10n = $l10n; - $this->permissionService = $permissionService; - $this->notificationHelper = $notificationHelper; - $this->assignedUsersMapper = $assignedUsersMapper; - $this->userManager = $userManager; - $this->groupManager = $groupManager; - $this->activityManager = $activityManager; - $this->eventDispatcher = $eventDispatcher; - $this->changeHelper = $changeHelper; - $this->userId = $userId; - $this->urlGenerator = $urlGenerator; - $this->connection = $connection; - $this->boardServiceValidator = $boardServiceValidator; - $this->sessionMapper = $sessionMapper; } /** @@ -150,7 +109,7 @@ public function findAll(int $since = -1, bool $fullDetails = false, bool $includ /** * @throws DoesNotExistException - * @throws \OCA\Deck\NoPermissionException + * @throws NoPermissionException * @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException * @throws BadRequestException */ @@ -177,7 +136,7 @@ public function find(int $boardId, bool $fullDetails = true, bool $allowDeleted * @param $id * @return bool * @throws DoesNotExistException - * @throws \OCA\Deck\NoPermissionException + * @throws NoPermissionException * @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException * @throws BadRequestException */ @@ -204,7 +163,7 @@ public function isArchived($mapper, $id) { * @param $id * @return bool * @throws DoesNotExistException - * @throws \OCA\Deck\NoPermissionException + * @throws NoPermissionException * @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException * @throws BadRequestException */ @@ -281,7 +240,7 @@ public function create($title, $userId, $color) { * @param $id * @return Board * @throws DoesNotExistException - * @throws \OCA\Deck\NoPermissionException + * @throws NoPermissionException * @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException * @throws BadRequestException */ @@ -305,7 +264,7 @@ public function delete($id) { * @param $id * @return \OCP\AppFramework\Db\Entity * @throws DoesNotExistException - * @throws \OCA\Deck\NoPermissionException + * @throws NoPermissionException * @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException */ public function deleteUndo($id) { @@ -325,7 +284,7 @@ public function deleteUndo($id) { * @param $id * @return \OCP\AppFramework\Db\Entity * @throws DoesNotExistException - * @throws \OCA\Deck\NoPermissionException + * @throws NoPermissionException * @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException * @throws BadRequestException */ @@ -346,7 +305,7 @@ public function deleteForce($id) { * @param $archived * @return \OCP\AppFramework\Db\Entity * @throws DoesNotExistException - * @throws \OCA\Deck\NoPermissionException + * @throws NoPermissionException * @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException * @throws BadRequestException */ @@ -411,7 +370,7 @@ public function enrichWithActiveSessions(Board $board) { * @param $manage * @return \OCP\AppFramework\Db\Entity * @throws BadRequestException - * @throws \OCA\Deck\NoPermissionException + * @throws NoPermissionException */ public function addAcl($boardId, $type, $participant, $edit, $share, $manage) { $this->boardServiceValidator->check(compact('boardId', 'type', 'participant', 'edit', 'share', 'manage')); @@ -455,7 +414,7 @@ public function addAcl($boardId, $type, $participant, $edit, $share, $manage) { * @param $manage * @return \OCP\AppFramework\Db\Entity * @throws DoesNotExistException - * @throws \OCA\Deck\NoPermissionException + * @throws NoPermissionException * @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException * @throws BadRequestException */ @@ -519,15 +478,16 @@ public function deleteAcl(int $id): ?Acl { } /** - * @param $id - * @param $userId - * @return Board - * @throws DoesNotExistException - * @throws \OCA\Deck\NoPermissionException - * @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException * @throws BadRequestException + * @throws DbException + * @throws DoesNotExistException + * @throws MultipleObjectsReturnedException + * @throws NoPermissionException */ - public function clone($id, $userId) { + public function clone( + int $id, string $userId, + bool $withCards = false, bool $withAssignments = false, bool $withLabels = false, bool $withDueDate = false, bool $moveCardsToLeftStack = false, bool $restoreArchivedCards = false, + ): Board { $this->boardServiceValidator->check(compact('id', 'userId')); if (!$this->permissionService->canCreate()) { @@ -550,6 +510,16 @@ public function clone($id, $userId) { ]); $this->boardMapper->insert($newBoard); + foreach ($this->aclMapper->findAll($board->getId()) as $acl) { + $this->addAcl($newBoard->getId(), + $acl->getType(), + $acl->getParticipant(), + $acl->getPermissionEdit(), + $acl->getPermissionShare(), + $acl->getPermissionManage()); + } + + $labels = $this->labelMapper->findAll($id); foreach ($labels as $label) { $newLabel = new Label(); @@ -572,6 +542,10 @@ public function clone($id, $userId) { $this->stackMapper->insert($newStack); } + if ($withCards) { + $this->cloneCards($board, $newBoard, $withAssignments, $withLabels, $withDueDate, $moveCardsToLeftStack, $restoreArchivedCards); + } + return $this->find($newBoard->getId()); } @@ -619,7 +593,7 @@ public function transferOwnership(string $owner, string $newOwner, bool $changeC * @param $id * @return Board * @throws DoesNotExistException - * @throws \OCA\Deck\NoPermissionException + * @throws NoPermissionException * @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException * @throws BadRequestException */ @@ -675,6 +649,83 @@ private function enrichBoards(array $boards, bool $fullDetails = true): array { return $boards; } + private function cloneCards(Board $board, Board $newBoard, bool $withAssignments = false, bool $withLabels = false, bool $withDueDate = false, bool $moveCardsToLeftStack = false, bool $restoreArchivedCards = false): void { + $stacks = $this->stackMapper->findAll($board->getId()); + $newStacks = $this->stackMapper->findAll($newBoard->getId()); + + $stackSorter = function (Stack $a, Stack $b) { + return $a->getOrder() - $b->getOrder(); + }; + usort($stacks, $stackSorter); + usort($newStacks, $stackSorter); + + $i = 0; + foreach ($stacks as $stack) { + $cards = $this->cardMapper->findAll($stack->getId()); + $archivedCards = $this->cardMapper->findAllArchived($stack->getId()); + + /** @var Card[] $cards */ + $cards = array_merge($cards, $archivedCards); + + foreach ($cards as $card) { + $targetStackId = $moveCardsToLeftStack ? $newStacks[0]->getId() : $newStacks[$i]->getId(); + + // Create a cloned card. + // Done with setters as only fields set via setters get written to db + $newCard = new Card(); + $newCard->setTitle($card->getTitle()); + $newCard->setDescription($card->getDescription()); + $newCard->setStackId($targetStackId); + $newCard->setType($card->getType()); + $newCard->setOwner($card->getOwner()); + $newCard->setOrder($card->getOrder()); + $newCard->setDuedate($withDueDate ? $card->getDuedate() : null); + $newCard->setArchived($restoreArchivedCards ? false : $card->getArchived()); + $newCard->setStackId($targetStackId); + + // Persist the cloned card. + $newCard = $this->cardMapper->insert($newCard); + + + // Copy labels. + if ($withLabels) { + $labels = $this->labelMapper->findAssignedLabelsForCard($card->getId()); + $newLabels = $this->labelMapper->findAll($newBoard->getId()); + $newLabelTitles = []; + foreach ($newLabels as $label) { + $newLabelTitles[$label->getTitle()] = $label; + } + + foreach ($labels as $label) { + $newLabelId = $newLabelTitles[$label->getTitle()]?->getId() ?? null; + if ($newLabelId) { + $this->cardMapper->assignLabel($newCard->getId(), $newLabelId); + } + } + } + + + // Copy assignments. + if ($withAssignments) { + $assignments = $this->assignedUsersMapper->findAll($card->getId()); + + foreach ($assignments as $assignment) { + $this->assignmentService->assignUser($newCard->getId(), $assignment->getParticipant(), $assignment->getType()); + } + } + + // Known limitation: Currently we do not copy attachments or comments + + // Copied from CardService because CardService cannot be injected due to cyclic dependencies. + $this->activityManager->triggerEvent(ActivityManager::DECK_OBJECT_CARD, $card, ActivityManager::SUBJECT_CARD_CREATE); + $this->changeHelper->cardChanged($card->getId(), false); + $this->eventDispatcher->dispatchTyped(new CardCreatedEvent($card)); + } + + $i++; + } + } + private function enrichWithStacks($board, $since = -1) { $stacks = $this->stackMapper->findAll($board->getId(), null, null, $since); diff --git a/src/components/navigation/AppNavigationBoard.vue b/src/components/navigation/AppNavigationBoard.vue index fe9316246..47282e1e2 100644 --- a/src/components/navigation/AppNavigationBoard.vue +++ b/src/components/navigation/AppNavigationBoard.vue @@ -12,7 +12,10 @@ :force-display-actions="isTouchDevice" @click="onNavigate" @undo="unDelete"> - +