Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
12 changes: 12 additions & 0 deletions lib/Controller/StackOcsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}

}
110 changes: 110 additions & 0 deletions lib/Service/StackService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
*
Expand Down
3 changes: 3 additions & 0 deletions src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
</div>
<KeyboardShortcuts />
<CardMoveDialog />
<StackMoveDialog />
</NcContent>
</template>

Expand All @@ -38,13 +39,15 @@ 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()

export default {
name: 'App',
components: {
CardMoveDialog,
StackMoveDialog,
AppNavigation,
NcModal,
NcContent,
Expand Down
142 changes: 142 additions & 0 deletions src/StackMoveDialog.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<NcDialog :open.sync="modalShow" :name="t('deck', 'Move/copy list')">
<div class="modal__content">
<NcSelect v-model="selectedBoard"
:input-label="t('deck', 'Select a board')"
:placeholder="t('deck', 'Select a board')"
:options="activeBoards"
:max-height="100"
data-cy="select-board"
label="title" />
<div v-if="loading" class="modal__progress">
<NcProgressBar :value="progress" size="medium" />
<span class="modal__progress-label">{{ progressLabel }}</span>
</div>
</div>
<template #actions>
<NcButton :disabled="!canSubmit" type="secondary" @click="moveStack">
{{ t('deck', 'Move list') }}
</NcButton>
<NcButton :disabled="!canSubmit" type="primary" @click="cloneStack">
{{ t('deck', 'Copy list') }}
</NcButton>
</template>
</NcDialog>
</template>

<script>
import { NcDialog, NcSelect, NcButton, NcProgressBar } from '@nextcloud/vue'
import { showError } from '@nextcloud/dialogs'
import { subscribe, unsubscribe } from '@nextcloud/event-bus'
import { mapGetters } from 'vuex'

export default {
name: 'StackMoveDialog',
components: { NcDialog, NcSelect, NcButton, NcProgressBar },
data() {
return {
stack: null,
modalShow: false,
selectedBoard: '',
loading: false,
progress: 0,
progressLabel: '',
progressTimer: null,
}
},
computed: {
...mapGetters(['boardById']),
activeBoards() {
return this.$store.getters.boards.filter((item) => item.deletedAt === 0 && item.archived === false && item.permissions.PERMISSION_MANAGE)
},
canSubmit() {
return !this.loading && this.selectedBoard !== ''
},
},
mounted() {
subscribe('deck:stack:show-move-dialog', this.openModal)
},
destroyed() {
unsubscribe('deck:stack:show-move-dialog', this.openModal)
this.stopProgress()
},
methods: {
openModal(stack) {
this.stack = stack
this.selectedBoard = this.boardById(stack.boardId) ?? ''
this.modalShow = true
},
async moveStack() {
await this.submit('moveStack', t('deck', 'Moving the list …'), t('deck', 'Could not move the list'))
},
async cloneStack() {
await this.submit('cloneStack', t('deck', 'Copying the list …'), t('deck', 'Could not copy the list'))
},
async submit(action, progressLabel, errorMessage) {
if (!this.canSubmit) {
return
}
this.loading = true
this.startProgress(progressLabel)
try {
await this.$store.dispatch(action, {
stackId: this.stack.id,
targetBoardId: this.selectedBoard.id,
})
// Fill the bar before closing so the completion is visible.
this.progress = 100
await new Promise((resolve) => setTimeout(resolve, 300))
this.modalShow = false
} catch (err) {
showError(errorMessage)
console.error(err)
} finally {
this.stopProgress()
this.loading = false
}
},
startProgress(label) {
this.progressLabel = label
this.progress = 0
// The server handles the whole operation in a single request, so
// real per-card progress is not available; advance the bar
// asymptotically towards 95% until the request settles.
this.progressTimer = setInterval(() => {
if (this.progress < 95) {
this.progress += (95 - this.progress) * 0.05
}
}, 150)
},
stopProgress() {
if (this.progressTimer) {
clearInterval(this.progressTimer)
this.progressTimer = null
}
this.progress = 0
},
},
}
</script>

<style lang="scss" scoped>
.modal__content {
.select {
margin-bottom: 12px;
}

.modal__progress {
margin-top: 16px;

.modal__progress-label {
display: block;
margin-top: 4px;
color: var(--color-text-maxcontrast);
font-size: 13px;
}
}
}
</style>
12 changes: 12 additions & 0 deletions src/components/board/Stack.vue
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,12 @@
</template>
{{ isDoneColumn ? t('deck', 'Do not set cards as "done"') : t('deck', 'Set cards as "done"') }}
</NcActionButton>
<NcActionButton close-after-click @click="openMoveDialog">
<template #icon>
<ExportVariant :size="20" />
</template>
{{ t('deck', 'Move/copy list') }}
</NcActionButton>
<NcActionButton icon="icon-delete" @click="deleteStack(stack)">
{{ t('deck', 'Delete list') }}
</NcActionButton>
Expand Down Expand Up @@ -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'

Expand All @@ -171,6 +179,7 @@ export default {
ArchiveIcon,
CardPlusOutline,
CheckCircleOutline,
ExportVariant,
},
directives: {
ClickOutside,
Expand Down Expand Up @@ -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
Expand Down
30 changes: 30 additions & 0 deletions src/services/StackApi.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading