diff --git a/appinfo/routes.php b/appinfo/routes.php index 146ec8d64..89a83074c 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -156,6 +156,8 @@ ['name' => 'stack_ocs#setDoneStack', 'url' => '/api/v{apiVersion}/stacks/{stackId}/done', 'verb' => 'PUT'], ['name' => 'stack_ocs#delete', 'url' => '/api/v{apiVersion}/stacks/{stackId}/{boardId}', 'verb' => 'DELETE', 'defaults' => ['boardId' => null]], ['name' => 'stack_ocs#reorder', 'url' => '/api/v{apiVersion}/stacks/{stackId}/reorder', 'verb' => 'PUT'], + ['name' => 'stack_ocs#move', 'url' => '/api/v{apiVersion}/stacks/{stackId}/move', 'verb' => 'PUT'], + ['name' => 'stack_ocs#clone', 'url' => '/api/v{apiVersion}/stacks/{stackId}/clone', 'verb' => 'POST'], ['name' => 'attachment_ocs#getAll', 'url' => '/api/v{apiVersion}/cards/{cardId}/attachments', 'verb' => 'GET'], ['name' => 'attachment_ocs#create', 'url' => '/api/v{apiVersion}/cards/{cardId}/attachment', 'verb' => 'POST'], diff --git a/lib/Controller/StackOcsController.php b/lib/Controller/StackOcsController.php index cada24f8e..b1371369d 100644 --- a/lib/Controller/StackOcsController.php +++ b/lib/Controller/StackOcsController.php @@ -91,4 +91,16 @@ public function reorder(int $stackId, int $order, ?int $boardId):DataResponse { return new DataResponse($stacks); } + #[NoAdminRequired] + #[PublicPage] + public function move(int $stackId, int $targetBoardId): DataResponse { + return new DataResponse($this->stackService->move($stackId, $targetBoardId)); + } + + #[NoAdminRequired] + #[PublicPage] + public function clone(int $stackId, int $targetBoardId): DataResponse { + return new DataResponse($this->stackService->cloneStack($stackId, $targetBoardId)); + } + } diff --git a/lib/Service/StackService.php b/lib/Service/StackService.php index 980de9b8e..ff9f5f516 100644 --- a/lib/Service/StackService.php +++ b/lib/Service/StackService.php @@ -36,6 +36,7 @@ public function __construct( private readonly PermissionService $permissionService, private readonly BoardService $boardService, private readonly CardService $cardService, + private readonly LabelService $labelService, private readonly AssignmentMapper $assignedUsersMapper, private readonly AttachmentService $attachmentService, private readonly ActivityManager $activityManager, @@ -295,6 +296,115 @@ public function reorder(int $id, int $order): array { return $result; } + /** + * Move a stack (with all of its cards) to another board. + * + * The stack keeps its identity, so the cards keep their comments, + * attachments and activity history. Board-specific labels assigned to the + * cards are remapped onto the target board by title (creating missing ones + * when the user may manage the target board), mirroring the behaviour of + * moving a single card across boards. + * + * @throws StatusException + * @throws \OCA\Deck\NoPermissionException + * @throws \OCP\AppFramework\Db\DoesNotExistException + * @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException + * @throws BadRequestException + */ + public function move(int $id, int $targetBoardId): Stack { + $this->permissionService->checkPermission($this->stackMapper, $id, Acl::PERMISSION_MANAGE); + $this->permissionService->checkPermission($this->boardMapper, $targetBoardId, Acl::PERMISSION_MANAGE); + + if ($this->boardService->isArchived($this->stackMapper, $id)) { + throw new StatusException('Operation not allowed. This board is archived.'); + } + if ($this->boardService->isArchived(null, $targetBoardId)) { + throw new StatusException('Operation not allowed. The target board is archived.'); + } + + $stack = $this->stackMapper->find($id); + $sourceBoardId = $stack->getBoardId(); + if ($sourceBoardId === $targetBoardId) { + $this->enrichStacksWithCards([$stack]); + return $stack; + } + + $changes = new ChangeSet($stack); + // Append the stack at the end of the target board. + $targetStacks = $this->stackMapper->findAll($targetBoardId); + $stack->setBoardId($targetBoardId); + $stack->setOrder(count($targetStacks)); + $stack = $this->stackMapper->update($stack); + $changes->setAfter($stack); + + // Remap each card's board-specific labels onto the target board. + foreach ($this->cardMapper->findAllByStack($id) as $card) { + foreach ($this->labelMapper->findAssignedLabelsForCard($card->getId()) as $label) { + $this->cardMapper->removeLabel($card->getId(), $label->getId()); + try { + $newLabel = $this->labelService->cloneLabelIfNotExists($label->getId(), $targetBoardId); + } catch (NoPermissionException $e) { + continue; + } + $this->cardMapper->assignLabel($card->getId(), $newLabel->getId()); + } + $this->changeHelper->cardChanged($card->getId()); + } + + $this->activityManager->triggerUpdateEvents( + ActivityManager::DECK_OBJECT_BOARD, $changes, ActivityManager::SUBJECT_STACK_UPDATE + ); + $this->changeHelper->boardChanged($sourceBoardId); + $this->changeHelper->boardChanged($targetBoardId); + $this->eventDispatcher->dispatchTyped(new BoardUpdatedEvent($sourceBoardId)); + $this->eventDispatcher->dispatchTyped(new BoardUpdatedEvent($targetBoardId)); + + $this->enrichStacksWithCards([$stack]); + return $stack; + } + + /** + * Copy a stack and all of its cards to another board (which may be the same + * board). The new cards are clones, so board-specific labels and + * assignments are remapped through {@see CardService::cloneCard()}. + * + * @throws StatusException + * @throws \OCA\Deck\NoPermissionException + * @throws \OCP\AppFramework\Db\DoesNotExistException + * @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException + * @throws BadRequestException + */ + public function cloneStack(int $id, int $targetBoardId): Stack { + $this->permissionService->checkPermission($this->stackMapper, $id, Acl::PERMISSION_READ); + $this->permissionService->checkPermission($this->boardMapper, $targetBoardId, Acl::PERMISSION_MANAGE); + + if ($this->boardService->isArchived(null, $targetBoardId)) { + throw new StatusException('Operation not allowed. The target board is archived.'); + } + + $sourceStack = $this->stackMapper->find($id); + $targetStacks = $this->stackMapper->findAll($targetBoardId); + + $newStack = new Stack(); + $newStack->setTitle($sourceStack->getTitle()); + $newStack->setBoardId($targetBoardId); + $newStack->setOrder(count($targetStacks)); + $newStack = $this->stackMapper->insert($newStack); + + foreach ($this->cardMapper->findAllByStack($id) as $card) { + $this->cardService->cloneCard($card->getId(), $newStack->getId()); + } + + $this->activityManager->triggerEvent( + ActivityManager::DECK_OBJECT_BOARD, $newStack, ActivityManager::SUBJECT_STACK_CREATE + ); + $this->changeHelper->boardChanged($targetBoardId); + $this->eventDispatcher->dispatchTyped(new BoardUpdatedEvent($targetBoardId)); + + $this->enrichStacksWithCards([$newStack]); + return $newStack; + } + /** * Set or unset a stack as the "done column" for the board * diff --git a/src/App.vue b/src/App.vue index 881f75ced..2ebec037e 100644 --- a/src/App.vue +++ b/src/App.vue @@ -26,6 +26,7 @@ + @@ -38,6 +39,7 @@ import { BoardApi } from './services/BoardApi.js' import { emit, subscribe } from '@nextcloud/event-bus' import { loadState } from '@nextcloud/initial-state' import CardMoveDialog from './CardMoveDialog.vue' +import StackMoveDialog from './StackMoveDialog.vue' const boardApi = new BoardApi() @@ -45,6 +47,7 @@ export default { name: 'App', components: { CardMoveDialog, + StackMoveDialog, AppNavigation, NcModal, NcContent, diff --git a/src/StackMoveDialog.vue b/src/StackMoveDialog.vue new file mode 100644 index 000000000..b6a76885b --- /dev/null +++ b/src/StackMoveDialog.vue @@ -0,0 +1,142 @@ + + + + + + diff --git a/src/components/board/Stack.vue b/src/components/board/Stack.vue index b208dccc8..ba23faaba 100644 --- a/src/components/board/Stack.vue +++ b/src/components/board/Stack.vue @@ -63,6 +63,12 @@ {{ isDoneColumn ? t('deck', 'Do not set cards as "done"') : t('deck', 'Set cards as "done"') }} + + + {{ t('deck', 'Move/copy list') }} + {{ t('deck', 'Delete list') }} @@ -152,8 +158,10 @@ import { Container, Draggable } from 'vue-smooth-dnd' import ArchiveIcon from 'vue-material-design-icons/ArchiveOutline.vue' import CardPlusOutline from 'vue-material-design-icons/CardPlusOutline.vue' import CheckCircleOutline from 'vue-material-design-icons/CheckCircleOutline.vue' +import ExportVariant from 'vue-material-design-icons/ExportVariant.vue' import { NcActions, NcActionButton, NcModal } from '@nextcloud/vue' import { showError, showUndo } from '@nextcloud/dialogs' +import { emit } from '@nextcloud/event-bus' import CardItem from '../cards/CardItem.vue' @@ -171,6 +179,7 @@ export default { ArchiveIcon, CardPlusOutline, CheckCircleOutline, + ExportVariant, }, directives: { ClickOutside, @@ -294,6 +303,9 @@ export default { this.$store.dispatch('deleteStack', stack) showUndo(t('deck', 'List deleted'), () => this.$store.dispatch('stackUndoDelete', stack)) }, + openMoveDialog() { + emit('deck:stack:show-move-dialog', this.stack) + }, setArchivedToAllCardsFromStack(stack, isArchived) { this.stackTransfer.total = this.cardsByStack.length diff --git a/src/services/StackApi.js b/src/services/StackApi.js index f5e26e45c..aea877610 100644 --- a/src/services/StackApi.js +++ b/src/services/StackApi.js @@ -128,6 +128,36 @@ export class StackApi { }) } + moveStack(stackId, targetBoardId) { + return axios.put(this.ocsUrl(`/stacks/${stackId}/move`), { targetBoardId }) + .then( + (response) => { + return Promise.resolve(response.data.ocs.data) + }, + (err) => { + return Promise.reject(err) + }, + ) + .catch((err) => { + return Promise.reject(err) + }) + } + + cloneStack(stackId, targetBoardId) { + return axios.post(this.ocsUrl(`/stacks/${stackId}/clone`), { targetBoardId }) + .then( + (response) => { + return Promise.resolve(response.data.ocs.data) + }, + (err) => { + return Promise.reject(err) + }, + ) + .catch((err) => { + return Promise.reject(err) + }) + } + setDoneStack(stackId, boardId, isDone) { return axios.put(this.ocsUrl(`/stacks/${stackId}/done`), { boardId, isDone }) .then( diff --git a/src/store/stack.js b/src/store/stack.js index d4ffafad8..023f8a6de 100644 --- a/src/store/stack.js +++ b/src/store/stack.js @@ -117,6 +117,29 @@ export default function stackModuleFactory() { commit('updateStack', stack) }) }, + async moveStack({ commit }, { stackId, targetBoardId }) { + const movedStack = await apiClient.moveStack(stackId, targetBoardId) + if (parseInt(targetBoardId) === parseInt(this.state.currentBoard.id)) { + // No-op move within the same board: keep the stack in view. + commit('addStack', movedStack) + } else { + // The stack now lives on another board; drop it from this one. + commit('deleteStack', { id: stackId }) + } + return movedStack + }, + async cloneStack({ commit }, { stackId, targetBoardId }) { + const newStack = await apiClient.cloneStack(stackId, targetBoardId) + if (parseInt(targetBoardId) === parseInt(this.state.currentBoard.id)) { + const cards = newStack.cards || [] + delete newStack.cards + commit('addStack', newStack) + for (const card of cards) { + commit('addCard', card) + } + } + return newStack + }, async setDoneStack({ commit, state, rootState }, { stackId, boardId, isDone }) { await apiClient.setDoneStack(stackId, boardId, isDone) // Mirror the backend bulk-clear: clear the flag on any other stack in this board diff --git a/tests/unit/Service/StackServiceTest.php b/tests/unit/Service/StackServiceTest.php index 4b7c6476e..63f20af9e 100644 --- a/tests/unit/Service/StackServiceTest.php +++ b/tests/unit/Service/StackServiceTest.php @@ -69,6 +69,8 @@ class StackServiceTest extends TestCase { private $boardService; /** @var CardService|\PHPUnit\Framework\MockObject\MockObject */ private $cardService; + /** @var LabelService|\PHPUnit\Framework\MockObject\MockObject */ + private $labelService; /** @var ActivityManager|\PHPUnit\Framework\MockObject\MockObject */ private $activityManager; /** @var ChangeHelper|\PHPUnit\Framework\MockObject\MockObject */ @@ -88,6 +90,7 @@ public function setUp(): void { $this->permissionService = $this->createMock(PermissionService::class); $this->boardService = $this->createMock(BoardService::class); $this->cardService = $this->createMock(CardService::class); + $this->labelService = $this->createMock(LabelService::class); $this->assignedUsersMapper = $this->createMock(AssignmentMapper::class); $this->attachmentService = $this->createMock(AttachmentService::class); $this->labelMapper = $this->createMock(LabelMapper::class); @@ -105,6 +108,7 @@ public function setUp(): void { $this->permissionService, $this->boardService, $this->cardService, + $this->labelService, $this->assignedUsersMapper, $this->attachmentService, $this->activityManager, @@ -319,4 +323,63 @@ public function testSetDoneStackThrowsOnArchivedBoard(): void { $this->expectException(\OCA\Deck\NoPermissionException::class); $this->stackService->setDoneStack(5, 1, true); } + + public function testMove(): void { + $this->permissionService->expects($this->exactly(2))->method('checkPermission'); + $this->boardService->method('isArchived')->willReturn(false); + $stack = new Stack(); + $stack->setId(1); + $stack->setBoardId(1); + $this->stackMapper->expects($this->once())->method('find')->with(1)->willReturn($stack); + // Two existing stacks on the target board -> moved stack lands at order 2. + $this->stackMapper->expects($this->once())->method('findAll')->with(2)->willReturn([new Stack(), new Stack()]); + $this->stackMapper->expects($this->once())->method('update')->willReturnArgument(0); + $this->cardMapper->expects($this->once())->method('findAllByStack')->with(1)->willReturn([]); + $this->cardMapper->method('findAllForStacks')->willReturn([]); + + $result = $this->stackService->move(1, 2); + + $this->assertEquals(2, $result->getBoardId()); + $this->assertEquals(2, $result->getOrder()); + } + + public function testMoveToSameBoardIsNoop(): void { + $this->boardService->method('isArchived')->willReturn(false); + $stack = new Stack(); + $stack->setId(1); + $stack->setBoardId(1); + $this->stackMapper->expects($this->once())->method('find')->with(1)->willReturn($stack); + $this->stackMapper->expects($this->never())->method('update'); + $this->cardMapper->method('findAllForStacks')->willReturn([]); + + $result = $this->stackService->move(1, 1); + + $this->assertEquals(1, $result->getBoardId()); + } + + public function testCloneStack(): void { + $this->permissionService->expects($this->exactly(2))->method('checkPermission'); + $this->boardService->method('isArchived')->willReturn(false); + $sourceStack = new Stack(); + $sourceStack->setId(1); + $sourceStack->setBoardId(1); + $sourceStack->setTitle('Todo'); + $newStack = new Stack(); + $newStack->setId(99); + $newStack->setBoardId(2); + $newStack->setTitle('Todo'); + $this->stackMapper->expects($this->once())->method('find')->with(1)->willReturn($sourceStack); + $this->stackMapper->expects($this->once())->method('findAll')->with(2)->willReturn([]); + $this->stackMapper->expects($this->once())->method('insert')->willReturn($newStack); + $card = new Card(); + $card->setId(5); + $this->cardMapper->expects($this->once())->method('findAllByStack')->with(1)->willReturn([$card]); + $this->cardService->expects($this->once())->method('cloneCard')->with(5, 99); + $this->cardMapper->method('findAllForStacks')->willReturn([]); + + $result = $this->stackService->cloneStack(1, 2); + + $this->assertEquals(2, $result->getBoardId()); + $this->assertEquals('Todo', $result->getTitle()); + } }