diff --git a/appinfo/routes.php b/appinfo/routes.php index 1f3208c53..0fec8568e 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -137,6 +137,8 @@ return [ ['name' => 'comments_api#update', 'url' => '/api/v{apiVersion}/cards/{cardId}/comments/{commentId}', 'verb' => 'PUT'], ['name' => 'comments_api#delete', 'url' => '/api/v{apiVersion}/cards/{cardId}/comments/{commentId}', 'verb' => 'DELETE'], + ['name' => 'card#clone', 'url' => '/api/v{apiVersion}/cards/{cardId}/clone', 'verb' => 'POST'], + ['name' => 'overview_api#upcomingCards', 'url' => '/api/v{apiVersion}/overview/upcoming', 'verb' => 'GET'], ['name' => 'search#search', 'url' => '/api/v{apiVersion}/search', 'verb' => 'GET'], diff --git a/cypress/e2e/cardFeatures.js b/cypress/e2e/cardFeatures.js index 905725e24..0e86f5b28 100644 --- a/cypress/e2e/cardFeatures.js +++ b/cypress/e2e/cardFeatures.js @@ -25,9 +25,9 @@ const useModal = (useModal) => { }) } -describe('Card', function() { +describe('Card', function () { let boardId - before(function() { + before(function () { cy.createUser(user) cy.login(user) cy.createExampleBoard({ @@ -38,11 +38,11 @@ describe('Card', function() { }) }) - beforeEach(function() { + beforeEach(function () { cy.login(user) }) - it('Can add a card', function() { + it('Can add a card', function () { cy.visit(`/apps/deck/#/board/${boardId}`) const newCardTitle = 'Write some cypress tests' @@ -63,7 +63,7 @@ describe('Card', function() { }) }) - it('Create card from overview', function() { + it('Create card from overview', function () { cy.visit(`/apps/deck/#/`) const newCardTitle = 'Test create from overview' cy.intercept({ method: 'POST', url: '**/apps/deck/cards' }).as('save') @@ -71,6 +71,10 @@ describe('Card', function() { cy.get('.button-vue[aria-label*="Add card"]') .first().click() + + // Somehow this avoids the electron crash + cy.wait(2000) + cy.get('.modal-mask.card-selector .card-title').should('be.visible').click().type(newCardTitle) cy.get('.modal-mask.card-selector .multiselect-board').should('be.visible').click() cy.get('.vs__dropdown-menu [data-cy="board-select-title"]:contains("' + boardData.title + '")').should('be.visible').click() @@ -91,14 +95,14 @@ describe('Card', function() { }) describe('Modal', () => { - beforeEach(function() { + beforeEach(function () { cy.login(user) useModal(true).then(() => { cy.visit(`/apps/deck/#/board/${boardId}`) }) }) - it('Can show card details modal', function() { + it('Can show card details modal', function () { cy.getNavigationEntry(boardData.title) .first().click({ force: true }) @@ -124,7 +128,7 @@ describe('Card', function() { cy.get('.attachment-list .basename').contains('welcome.txt') }) - it.only('Shows the modal with the editor', () => { + it('Shows the modal with the editor', () => { cy.get('.card:contains("Hello world")').should('be.visible').click() cy.intercept({ method: 'PUT', url: '**/apps/deck/cards/*' }).as('save') cy.get('.modal__card').should('be.visible') @@ -161,9 +165,9 @@ describe('Card', function() { cy.get('.reference-picker-modal--content .reference-picker .multiselect-list').should('be.visible').contains(boardData.stacks[0].title) cy.get('.reference-picker-modal--content .reference-picker button.button-vue--vue-primary').should('be.visible').click() cy.wait('@save', { timeout: 7000 }) - cy.get('.modal__card .ProseMirror').contains('/index.php/apps/deck/card/').should('be.visible') + cy.get('.modal__card .ProseMirror').contains('/index.php/apps/deck/card/').should('have.length', 1) - cy.visit(`/apps/deck/#/board/${boardId}`) + cy.visit(`/apps/deck/board/${boardId}`) cy.reload() cy.get('.board .stack').eq(0).within(() => { cy.get(`.card:contains("${newCardTitle}")`).should('be.visible') @@ -172,7 +176,7 @@ describe('Card', function() { }) describe('Sidebar', () => { - beforeEach(function() { + beforeEach(function () { cy.login(user) useModal(false).then(() => { cy.visit(`/apps/deck/#/board/${boardId}`) @@ -185,7 +189,7 @@ describe('Card', function() { .find('.ProseMirror h1').contains('Hello world writing more text').should('be.visible') }) - it('Set a due date', function() { + it('Set a due date', function () { const newCardTitle = 'Card with a due date' cy.get('.button-vue[aria-label*="Add card"]') @@ -223,7 +227,7 @@ describe('Card', function() { cy.get(`.card:contains("${newCardTitle}")`).find('[data-due-state]').should('not.exist') }) - it('Add a label', function() { + it('Add a label', function () { const newCardTitle = 'Card with labels' cy.get('.button-vue[aria-label*="Add card"]') @@ -252,7 +256,7 @@ describe('Card', function() { }) describe('Card actions', () => { - beforeEach(function() { + beforeEach(function () { cy.login(user) useModal(false).then(() => { cy.visit(`/apps/deck/#/board/${boardId}`) @@ -298,5 +302,18 @@ describe('Card', function() { }) }) }) + + it('clone card', () => { + cy.intercept({ method: 'POST', url: '**/apps/deck/**/cards/*/clone' }).as('clone') + cy.get('.card:contains("Hello world")').should('be.visible').click() + cy.get('#app-sidebar-vue') + .find('.ProseMirror h1').contains('Hello world').should('be.visible') + + cy.get('.app-sidebar-header .action-item__menutoggle').click() + cy.get('.v-popper__popper button:contains("Move/copy card")').click() + cy.get('.modal-container button:contains("Copy card")').click() + cy.wait('@clone', { timeout: 7000 }) + cy.get('.card:contains("Hello world")').should('have.length', 2) + }) }) }) diff --git a/lib/Controller/CardController.php b/lib/Controller/CardController.php index c6ea2edf1..c0e6d10c3 100644 --- a/lib/Controller/CardController.php +++ b/lib/Controller/CardController.php @@ -90,6 +90,15 @@ class CardController extends Controller { public function update($id, $title, $stackId, $type, $order, $description, $duedate, $deletedAt) { return $this->cardService->update($id, $title, $stackId, $type, $this->userId, $description, $order, $duedate, $deletedAt); } + /** + * @NoAdminRequired + * @param $cardId + * @param $targetStackId + * @return \OCP\AppFramework\Db\Entity + */ + public function clone(int $cardId, ?int $targetStackId = null) { + return $this->cardService->cloneCard($cardId, $targetStackId); + } /** * @NoAdminRequired diff --git a/lib/Service/CardService.php b/lib/Service/CardService.php index fbce40646..15f659df9 100644 --- a/lib/Service/CardService.php +++ b/lib/Service/CardService.php @@ -37,69 +37,33 @@ use OCP\IUserManager; use Psr\Log\LoggerInterface; class CardService { - private CardMapper $cardMapper; - private StackMapper $stackMapper; - private BoardMapper $boardMapper; - private LabelMapper $labelMapper; - private LabelService $labelService; - private PermissionService $permissionService; - private BoardService $boardService; - private NotificationHelper $notificationHelper; - private AssignmentMapper $assignedUsersMapper; - private AttachmentService $attachmentService; - private ?string $currentUser; - private ActivityManager $activityManager; - private ICommentsManager $commentsManager; - private ChangeHelper $changeHelper; - private IEventDispatcher $eventDispatcher; - private IUserManager $userManager; - private IURLGenerator $urlGenerator; - private LoggerInterface $logger; - private IRequest $request; - private CardServiceValidator $cardServiceValidator; + + private string $currentUser; public function __construct( - CardMapper $cardMapper, - StackMapper $stackMapper, - BoardMapper $boardMapper, - LabelMapper $labelMapper, - LabelService $labelService, - PermissionService $permissionService, - BoardService $boardService, - NotificationHelper $notificationHelper, - AssignmentMapper $assignedUsersMapper, - AttachmentService $attachmentService, - ActivityManager $activityManager, - ICommentsManager $commentsManager, - IUserManager $userManager, - ChangeHelper $changeHelper, - IEventDispatcher $eventDispatcher, - IURLGenerator $urlGenerator, - LoggerInterface $logger, - IRequest $request, - CardServiceValidator $cardServiceValidator, + private CardMapper $cardMapper, + private StackMapper $stackMapper, + private BoardMapper $boardMapper, + private LabelMapper $labelMapper, + private LabelService $labelService, + private PermissionService $permissionService, + private BoardService $boardService, + private NotificationHelper $notificationHelper, + private AssignmentMapper $assignedUsersMapper, + private AttachmentService $attachmentService, + private ActivityManager $activityManager, + private ICommentsManager $commentsManager, + private IUserManager $userManager, + private ChangeHelper $changeHelper, + private IEventDispatcher $eventDispatcher, + private IURLGenerator $urlGenerator, + private LoggerInterface $logger, + private IRequest $request, + private CardServiceValidator $cardServiceValidator, + private AssignmentService $assignmentService, ?string $userId, ) { - $this->cardMapper = $cardMapper; - $this->stackMapper = $stackMapper; - $this->boardMapper = $boardMapper; - $this->labelMapper = $labelMapper; - $this->labelService = $labelService; - $this->permissionService = $permissionService; - $this->boardService = $boardService; - $this->notificationHelper = $notificationHelper; - $this->assignedUsersMapper = $assignedUsersMapper; - $this->attachmentService = $attachmentService; - $this->activityManager = $activityManager; - $this->commentsManager = $commentsManager; - $this->userManager = $userManager; - $this->changeHelper = $changeHelper; - $this->eventDispatcher = $eventDispatcher; $this->currentUser = $userId; - $this->urlGenerator = $urlGenerator; - $this->logger = $logger; - $this->request = $request; - $this->cardServiceValidator = $cardServiceValidator; } public function enrichCards($cards) { @@ -391,6 +355,38 @@ class CardService { return $card; } + public function cloneCard(int $id, ?int $targetStackId = null):Card { + $this->permissionService->checkPermission($this->cardMapper, $id, Acl::PERMISSION_READ); + $originCard = $this->cardMapper->find($id); + if ($targetStackId === null) { + $targetStackId = $originCard->getStackId(); + } + $this->permissionService->checkPermission($this->stackMapper, $targetStackId, Acl::PERMISSION_EDIT); + $newCard = $this->create($originCard->getTitle(), $targetStackId, $originCard->getType(), $originCard->getOrder(), $originCard->getOwner()); + $boardId = $this->stackMapper->findBoardId($targetStackId); + foreach ($this->labelMapper->findAssignedLabelsForCard($id) as $label) { + if ($boardId != $this->stackMapper->findBoardId($originCard->getStackId())) { + try { + $label = $this->labelService->cloneLabelIfNotExists($label->getId(), $boardId); + } catch (NoPermissionException $e) { + break; + } + } + $this->assignLabel($newCard->getId(), $label->getId()); + } + foreach ($this->assignedUsersMapper->findAll($id) as $assignement) { + try { + $this->permissionService->checkPermission($this->cardMapper, $newCard->getId(), Acl::PERMISSION_READ, $assignement->getParticipant()); + } catch (NoPermissionException $e) { + continue; + } + $this->assignmentService->assignUser($newCard->getId(), $assignement->getParticipant()); + } + $newCard->setDescription($originCard->getDescription()); + $card = $this->enrichCards([$this->cardMapper->update($newCard)]); + return $card[0]; + } + /** * @param $id * @param $title diff --git a/lib/Service/LabelService.php b/lib/Service/LabelService.php index 98150a62c..69f372c42 100644 --- a/lib/Service/LabelService.php +++ b/lib/Service/LabelService.php @@ -93,6 +93,18 @@ class LabelService { return $this->labelMapper->insert($label); } + public function cloneLabelIfNotExists(int $labelId, int $targetBoardId): Label { + $this->permissionService->checkPermission(null, $targetBoardId, Acl::PERMISSION_MANAGE); + $boardLabels = $this->boardService->find($targetBoardId)->getLabels(); + $originLabel = $this->find($labelId); + $filteredValues = array_values(array_filter($boardLabels, fn ($item) => $item->getTitle() === $originLabel->getTitle())); + if (empty($filteredValues)) { + $label = $this->create($originLabel->getTitle(), $originLabel->getColor(), $targetBoardId); + return $label; + } + return $originLabel; + } + /** * @param $id * @return \OCP\AppFramework\Db\Entity diff --git a/src/CardMoveDialog.vue b/src/CardMoveDialog.vue index 59a42a579..c7d5d6fba 100644 --- a/src/CardMoveDialog.vue +++ b/src/CardMoveDialog.vue @@ -3,9 +3,8 @@ - SPDX-License-Identifier: AGPL-3.0-or-later --> - + - {{ t('deck', 'Move card to another board') }} - - - {{ t('deck', 'Move card') }} - - - {{ t('deck', 'Cancel') }} - - + + + {{ t('deck', 'Move card') }} + + + {{ t('deck', 'Copy card') }} + + + diff --git a/src/components/cards/CardMenuEntries.vue b/src/components/cards/CardMenuEntries.vue index 23a9d2fb3..4c6874605 100644 --- a/src/components/cards/CardMenuEntries.vue +++ b/src/components/cards/CardMenuEntries.vue @@ -31,7 +31,7 @@ icon="icon-external" :close-after-click="true" @click="openCardMoveDialog"> - {{ t('deck', 'Move card') }} + {{ t('deck', 'Move/copy card') }} { + return Promise.resolve(response.data) + }, + (err) => { + return Promise.reject(err) + }, + ) + .catch((err) => { + return Promise.reject(err) + }) + } + deleteCard(cardId) { return axios.delete(this.url(`/cards/${cardId}`)) .then( diff --git a/src/store/card.js b/src/store/card.js index 07c3e389d..63f85eb50 100644 --- a/src/store/card.js +++ b/src/store/card.js @@ -272,6 +272,11 @@ export default { }, }, actions: { + async cloneCard({ commit }, { cardId, targetStackId }) { + const createdCard = await apiClient.cloneCard(cardId, targetStackId) + commit('addCard', createdCard) + return createdCard + }, async addCard({ commit }, card) { const createdCard = await apiClient.addCard(card) commit('addCard', createdCard) diff --git a/tests/unit/Service/CardServiceTest.php b/tests/unit/Service/CardServiceTest.php index 92280c993..e5600b3ae 100644 --- a/tests/unit/Service/CardServiceTest.php +++ b/tests/unit/Service/CardServiceTest.php @@ -32,6 +32,7 @@ use OCA\Deck\Db\BoardMapper; use OCA\Deck\Db\Card; use OCA\Deck\Db\CardMapper; use OCA\Deck\Db\ChangeHelper; +use OCA\Deck\Db\Label; use OCA\Deck\Db\LabelMapper; use OCA\Deck\Db\Stack; use OCA\Deck\Db\StackMapper; @@ -93,6 +94,9 @@ class CardServiceTest extends TestCase { /** @var CardServiceValidator|MockObject */ private $cardServiceValidator; + /** @var AssignmentService|MockObject */ + private $assignmentService; + public function setUp(): void { parent::setUp(); $this->cardMapper = $this->createMock(CardMapper::class); @@ -114,6 +118,7 @@ class CardServiceTest extends TestCase { $this->logger = $this->createMock(LoggerInterface::class); $this->request = $this->createMock(IRequest::class); $this->cardServiceValidator = $this->createMock(CardServiceValidator::class); + $this->assignmentService = $this->createMock(AssignmentService::class); $this->logger->expects($this->any())->method('error'); @@ -137,6 +142,7 @@ class CardServiceTest extends TestCase { $this->logger, $this->request, $this->cardServiceValidator, + $this->assignmentService, 'user1' ); } @@ -219,6 +225,61 @@ class CardServiceTest extends TestCase { $this->assertEquals($b->getStackId(), 123); } + public function testClone() { + $card = new Card(); + $card->setId(1); + $card->setTitle('Card title'); + $card->setOwner('admin'); + $card->setStackId(12345); + $clonedCard = clone $card; + $clonedCard->setId(2); + $clonedCard->setStackId(1234); + $this->cardMapper->expects($this->exactly(2)) + ->method('insert') + ->willReturn($card, $clonedCard); + + $this->cardMapper->expects($this->once()) + ->method('update')->willReturn($clonedCard); + $this->cardMapper->expects($this->exactly(2)) + ->method('find') + ->willReturn($card, $clonedCard); + + // check if users are assigned + $this->assignmentService->expects($this->once()) + ->method('assignUser') + ->with(2, 'admin'); + $a1 = new Assignment(); + $a1->setCardId(2); + $a1->setType(0); + $a1->setParticipant('admin'); + $this->assignedUsersMapper->expects($this->once()) + ->method('findAll') + ->with(1) + ->willReturn([$a1]); + + // check if labels get cloned + $label = new Label(); + $label->setId(1); + $this->labelMapper->expects($this->once()) + ->method('findAssignedLabelsForCard') + ->willReturn([$label]); + $this->cardMapper->expects($this->once()) + ->method('assignLabel') + ->with($clonedCard->getId(), $label->getId()) + ->willReturn($label); + + $stackMock = new Stack(); + $stackMock->setBoardId(1234); + $this->stackMapper->expects($this->once()) + ->method('find') + ->willReturn($stackMock); + $b = $this->cardService->create('Card title', 123, 'text', 999, 'admin'); + $c = $this->cardService->cloneCard($b->getId(), 1234); + $this->assertEquals($b->getTitle(), $c->getTitle()); + $this->assertEquals($b->getOwner(), $c->getOwner()); + $this->assertNotEquals($b->getStackId(), $c->getStackId()); + } + public function testDelete() { $cardToBeDeleted = new Card(); $this->cardMapper->expects($this->once()) diff --git a/tests/unit/Service/LabelServiceTest.php b/tests/unit/Service/LabelServiceTest.php index 3c61b8ad2..c492a972c 100644 --- a/tests/unit/Service/LabelServiceTest.php +++ b/tests/unit/Service/LabelServiceTest.php @@ -24,6 +24,7 @@ namespace OCA\Deck\Service; +use OCA\Deck\Db\Board; use OCA\Deck\Db\ChangeHelper; use OCA\Deck\Db\Label; use OCA\Deck\Db\LabelMapper; @@ -105,6 +106,53 @@ class LabelServiceTest extends TestCase { $this->assertEquals($b->getColor(), 'ffffff'); } + public function testCloneLabelIfNotExists() { + $label = new Label(); + $label->setId(1); + $label->setTitle('title'); + $label->setColor('00ff00'); + $this->labelMapper->expects($this->once()) + ->method('find') + ->willReturn($label); + + $expectedLabel = new Label(); + $expectedLabel->setTitle('title'); + $expectedLabel->setColor('00ff00'); + $expectedLabel->setBoardId(1); + $this->labelMapper->expects($this->once()) + ->method('insert') + ->with($expectedLabel) + ->willReturn($label); + $board = new Board(); + $board->setLabels([]); + $this->boardService->expects($this->once()) + ->method('find') + ->willReturn($board); + + $this->labelService->cloneLabelIfNotExists(1, 1); + } + + public function testCloneLabelIfExists() { + $label = new Label(); + $label->setId(1); + $label->setTitle('title'); + $label->setColor('00ff00'); + $this->labelMapper->expects($this->once()) + ->method('find') + ->willReturn($label); + $this->labelMapper->expects($this->never()) + ->method('insert') + ->with($label); + $board = new Board(); + $board->setLabels([$label]); + $this->boardService->expects($this->once()) + ->method('find') + ->willReturn($board); + + $this->labelService->cloneLabelIfNotExists(1, 1); + + } + public function testDelete() { $label = new Label(); $label->setId(1);