Merge pull request #6452 from nextcloud/feat/1813-clonecopy-a-card

feat(cards): add card cloning ability
This commit is contained in:
Julius Knorr
2024-12-19 16:26:13 +01:00
committed by GitHub
11 changed files with 263 additions and 101 deletions

View File

@@ -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'],

View File

@@ -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()
@@ -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')
@@ -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)
})
})
})

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -3,9 +3,8 @@
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<NcModal v-if="modalShow" :title="t('deck', 'Move card to another board')" @close="modalShow=false">
<NcDialog :open.sync="modalShow" :name="t('deck', 'Move/copy card')">
<div class="modal__content">
<h3>{{ t('deck', 'Move card to another board') }}</h3>
<NcSelect v-model="selectedBoard"
:input-label="t('deck', 'Select a board')"
:placeholder="t('deck', 'Select a board')"
@@ -20,26 +19,28 @@
:options="stacksFromBoard"
:max-height="100"
label="title" />
<button :disabled="!isBoardAndStackChoosen" class="primary" @click="moveCard">
{{ t('deck', 'Move card') }}
</button>
<button @click="modalShow=false">
{{ t('deck', 'Cancel') }}
</button>
</div>
</NcModal>
<template #actions>
<NcButton :disabled="!isBoardAndStackChoosen" type="secondary" @click="moveCard">
{{ t('deck', 'Move card') }}
</NcButton>
<NcButton :disabled="!isBoardAndStackChoosen" type="primary" @click="cloneCard">
{{ t('deck', 'Copy card') }}
</NcButton>
</template>
</NcDialog>
</template>
<script>
import { NcModal, NcSelect } from '@nextcloud/vue'
import { NcDialog, NcSelect, NcButton } from '@nextcloud/vue'
import { generateUrl } from '@nextcloud/router'
import axios from '@nextcloud/axios'
import { subscribe, unsubscribe } from '@nextcloud/event-bus'
import { mapGetters } from 'vuex'
export default {
name: 'CardMoveDialog',
components: { NcModal, NcSelect },
components: { NcDialog, NcSelect, NcButton },
data() {
return {
card: null,
@@ -50,6 +51,7 @@ export default {
}
},
computed: {
...mapGetters(['stackById', 'boardById']),
activeBoards() {
return this.$store.getters.boards.filter((item) => item.deletedAt === 0 && item.archived === false)
},
@@ -66,6 +68,9 @@ export default {
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) {
@@ -81,33 +86,23 @@ 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
},
async cloneCard() {
this.$store.dispatch('cloneCard', { cardId: this.card.id, targetStackId: this.selectedStack.id })
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>

View File

@@ -31,7 +31,7 @@
icon="icon-external"
:close-after-click="true"
@click="openCardMoveDialog">
{{ t('deck', 'Move card') }}
{{ t('deck', 'Move/copy card') }}
</NcActionButton>
<NcActionButton v-for="action in cardActions"
:key="action.label"

View File

@@ -4,7 +4,7 @@
*/
import axios from '@nextcloud/axios'
import { generateUrl } from '@nextcloud/router'
import { generateOcsUrl, generateUrl } from '@nextcloud/router'
export class CardApi {
@@ -28,6 +28,23 @@ export class CardApi {
})
}
cloneCard(cardId, targetStackId) {
return axios.post(generateOcsUrl(`apps/deck/api/v1.0/cards/${cardId}/clone`), {
targetStackId,
})
.then(
(response) => {
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(

View File

@@ -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)

View File

@@ -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())

View File

@@ -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);