Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(cards): add card cloning ability #6452

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
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
1 change: 1 addition & 0 deletions appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
['name' => 'card#read', 'url' => '/cards/{cardId}', 'verb' => 'GET'],
['name' => 'card#create', 'url' => '/cards', 'verb' => 'POST'],
['name' => 'card#update', 'url' => '/cards/{cardId}', 'verb' => 'PUT'],
['name' => 'card#clone', 'url' => '/cards/{cardId}/clone', 'verb' => 'POST'],
['name' => 'card#delete', 'url' => '/cards/{cardId}', 'verb' => 'DELETE'],
['name' => 'card#deleted', 'url' => '/{boardId}/cards/deleted', 'verb' => 'GET'],
['name' => 'card#rename', 'url' => '/cards/{cardId}/rename', 'verb' => 'PUT'],
Expand Down
9 changes: 9 additions & 0 deletions lib/Controller/CardController.php
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,15 @@ public function create($title, $stackId, $type = 'plain', $order = 999, string $
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
Expand Down
112 changes: 54 additions & 58 deletions lib/Service/CardService.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,69 +36,33 @@
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,
grnd-alt marked this conversation as resolved.
Show resolved Hide resolved
?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) {
Expand Down Expand Up @@ -390,6 +354,38 @@ public function update($id, $title, $stackId, $type, $owner, $description = '',
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());
grnd-alt marked this conversation as resolved.
Show resolved Hide resolved
}
$newCard->setDescription($originCard->getDescription());
$card = $this->enrichCards([$this->cardMapper->update($newCard)]);
return $card[0];
}

/**
* @param $id
* @param $title
Expand Down
12 changes: 12 additions & 0 deletions lib/Service/LabelService.php
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,18 @@ public function create($title, $color, $boardId) {
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
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 />
<CardCloneDialog />
</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 CardCloneDialog from './CardCloneDialog.vue'

const boardApi = new BoardApi()

export default {
name: 'App',
components: {
CardMoveDialog,
CardCloneDialog,
AppNavigation,
NcModal,
NcContent,
Expand Down
121 changes: 121 additions & 0 deletions src/CardCloneDialog.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
<!--
- SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<NcModal v-if="modalShow" :title="t('deck', 'Clone card')" @close="modalShow = false">
<div class="modal__content">
<h3>{{ t('deck', 'Clone card to another board') }}</h3>
<NcSelect v-model="selectedBoard"
:input-label="t('deck', 'Select a board')"
:placeholder="t('deck', 'Select a board')"
:options="activeBoards"
:max-height="100"
label="title"
@option:selected="loadStacksFromBoard" />
<NcSelect v-model="selectedStack"
:disabled="stacksFromBoard.length === 0"
:placeholder="stacksFromBoard.length === 0 ? t('deck', 'No lists available') : t('deck', 'Select a list')"
:input-label="t('deck', 'Select a list')"
:options="stacksFromBoard"
:max-height="100"
label="title" />

<button :disabled="!isBoardAndStackChoosen" class="primary" @click="cloneCard">
{{ t('deck', 'Clone card') }}
</button>
<button @click="modalShow = false">
{{ t('deck', 'Cancel') }}
</button>
</div>
</NcModal>
</template>

<script>
import { NcModal, NcSelect } from '@nextcloud/vue'
import { generateUrl } from '@nextcloud/router'
import axios from '@nextcloud/axios'
import { subscribe, unsubscribe } from '@nextcloud/event-bus'
import { mapGetters, mapState } from 'vuex'

export default {
name: 'CardCloneDialog',
components: { NcModal, NcSelect },
data() {
return {
card: null,
modalShow: false,
selectedBoard: '',
selectedStack: '',
stacksFromBoard: [],
}
},
computed: {
...mapGetters([
'boards',
'stackById',
'boardById',
]),
...mapState({
currentBoard: state => state.currentBoard,
}),
activeBoards() {
return this.$store.getters.boards.filter((item) => item.deletedAt === 0 && item.archived === false)
},
isBoardAndStackChoosen() {
return !(this.selectedBoard === '' || this.selectedStack === '')
},
},
mounted() {
subscribe('deck:card:show-clone-dialog', this.openModal)
},
destroyed() {
unsubscribe('deck:card:show-clone-dialog', this.openModal)
},
methods: {
openModal(card) {
this.card = card
this.selectedStack = this.stackById(this.card.stackId)
this.selectedBoard = this.boardById(this.selectedStack.boardId)
this.loadStacksFromBoard(this.selectedBoard)
this.modalShow = true
},
async loadStacksFromBoard(board) {
try {
const url = generateUrl('/apps/deck/stacks/' + board.id)
const response = await axios.get(url)
this.stacksFromBoard = response.data
} catch (err) {
return err
}
},
async cloneCard() {
this.$store.dispatch('cloneCard', { cardId: this.card.id, targetStackId: this.selectedStack.id })
this.$store.dispatch('addCard', this.copiedCard)
this.modalShow = false
},
},
}
</script>

<style lang="scss" scoped>
.modal__content {
min-width: 250px;
min-height: 120px;
margin: 20px 20px 100px 20px;

h3 {
font-weight: bold;
text-align: center;
}

.select {
margin-bottom: 12px;
}
}

.modal__content button {
float: right;
margin-top: 50px;
}
</style>
6 changes: 3 additions & 3 deletions src/CardMoveDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<NcModal v-if="modalShow" :title="t('deck', 'Move card to another board')" @close="modalShow=false">
<NcModal v-if="modalShow" :title="t('deck', 'Move card to another board')" @close="modalShow = false">
<div class="modal__content">
<h3>{{ t('deck', 'Move card to another board') }}</h3>
<NcSelect v-model="selectedBoard"
Expand All @@ -24,7 +24,7 @@
<button :disabled="!isBoardAndStackChoosen" class="primary" @click="moveCard">
{{ t('deck', 'Move card') }}
</button>
<button @click="modalShow=false">
<button @click="modalShow = false">
{{ t('deck', 'Cancel') }}
</button>
</div>
Expand Down Expand Up @@ -81,7 +81,7 @@ export default {
this.copiedCard = Object.assign({}, this.card)
this.copiedCard.stackId = this.selectedStack.id
this.$store.dispatch('moveCard', this.copiedCard)
if (parseInt(this.boardId) === parseInt(this.selectedStack.boardId)) {
if (parseInt(this.selectedBoard.id) === parseInt(this.selectedStack.boardId)) {
await this.$store.commit('addNewCard', { ...this.copiedCard })
}
this.modalShow = false
Expand Down
14 changes: 13 additions & 1 deletion src/components/cards/CardMenuEntries.vue
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,14 @@
@click="openCardMoveDialog">
{{ t('deck', 'Move card') }}
</NcActionButton>
<NcActionButton v-if="canEdit"
:close-after-click="true"
@click="openCardCloneDialog">
<template #icon>
<CloneIcon :size="20" decorative />
</template>
{{ t('deck', 'Clone card') }}
</NcActionButton>
<NcActionButton v-for="action in cardActions"
:key="action.label"
:close-after-click="true"
Expand All @@ -58,6 +66,7 @@
import { NcActionButton } from '@nextcloud/vue'
import { mapGetters, mapState } from 'vuex'
import ArchiveIcon from 'vue-material-design-icons/Archive.vue'
import CloneIcon from 'vue-material-design-icons/ContentCopy.vue'
import CardBulletedIcon from 'vue-material-design-icons/CardBulleted.vue'
import { generateUrl } from '@nextcloud/router'
import { getCurrentUser } from '@nextcloud/auth'
Expand All @@ -68,7 +77,7 @@ import { emit } from '@nextcloud/event-bus'

export default {
name: 'CardMenuEntries',
components: { NcActionButton, ArchiveIcon, CardBulletedIcon },
components: { CloneIcon, NcActionButton, ArchiveIcon, CardBulletedIcon },
props: {
card: {
type: Object,
Expand Down Expand Up @@ -168,6 +177,9 @@ export default {
openCardMoveDialog() {
emit('deck:card:show-move-dialog', this.card)
},
openCardCloneDialog() {
emit('deck:card:show-clone-dialog', this.card)
},
},
}
</script>
Loading
Loading