diff --git a/appinfo/routes.php b/appinfo/routes.php index 685a6b8756..7e9aadc5d2 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -57,6 +57,8 @@ ['name' => 'card#removeLabel', 'url' => '/cards/{cardId}/label/{labelId}', 'verb' => 'DELETE'], ['name' => 'card#assignUser', 'url' => '/cards/{cardId}/assign', 'verb' => 'POST'], ['name' => 'card#unassignUser', 'url' => '/cards/{cardId}/unassign', 'verb' => 'PUT'], + ['name' => 'card#assignDependentCard', 'url' => '/cards/{cardId}/dependentCards/{dependentCardId}', 'verb' => 'POST'], + ['name' => 'card#removeDependentCard', 'url' => '/cards/{cardId}/dependentCards/{dependentCardId}', 'verb' => 'DELETE'], // attachments ['name' => 'attachment#getAll', 'url' => '/cards/{cardId}/attachments', 'verb' => 'GET'], @@ -105,6 +107,8 @@ ['name' => 'card_api#assignUser', 'url' => '/api/v{apiVersion}/boards/{boardId}/stacks/{stackId}/cards/{cardId}/assignUser', 'verb' => 'PUT'], ['name' => 'card_api#unassignUser', 'url' => '/api/v{apiVersion}/boards/{boardId}/stacks/{stackId}/cards/{cardId}/unassignUser', 'verb' => 'PUT'], ['name' => 'card_api#reorder', 'url' => '/api/v{apiVersion}/boards/{boardId}/stacks/{stackId}/cards/{cardId}/reorder', 'verb' => 'PUT'], + ['name' => 'card_api#assignDependentCard', 'url' => '/api/v{apiVersion}/boards/{boardId}/stacks/{stackId}/cards/{cardId}/dependentCards/{dependentCardId}', 'verb' => 'POST'], + ['name' => 'card_api#removeDependentCard', 'url' => '/api/v{apiVersion}/boards/{boardId}/stacks/{stackId}/cards/{cardId}/dependentCards/{dependentCardId}', 'verb' => 'DELETE'], ['name' => 'card_api#archive', 'url' => '/api/v{apiVersion}/boards/{boardId}/stacks/{stackId}/cards/{cardId}/archive', 'verb' => 'PUT'], ['name' => 'card_api#unarchive', 'url' => '/api/v{apiVersion}/boards/{boardId}/stacks/{stackId}/cards/{cardId}/unarchive', 'verb' => 'PUT'], ['name' => 'card_api#delete', 'url' => '/api/v{apiVersion}/boards/{boardId}/stacks/{stackId}/cards/{cardId}', 'verb' => 'DELETE'], @@ -146,6 +150,8 @@ ['name' => 'card_ocs#unAssignUser', 'url' => '/api/v{apiVersion}/cards/{cardId}/unassign', 'verb' => 'PUT'], ['name' => 'card_ocs#removeLabel', 'url' => '/api/v{apiVersion}/cards/{cardId}/label/{labelId}', 'verb' => 'DELETE'], ['name' => 'card_ocs#reorder', 'url' => '/api/v{apiVersion}/cards/{cardId}/reorder', 'verb' => 'PUT'], + ['name' => 'card_ocs#assignDependentCard', 'url' => '/api/v{apiVersion}/cards/{cardId}/dependentCards/{dependentCardId}', 'verb' => 'POST'], + ['name' => 'card_ocs#removeDependentCard', 'url' => '/api/v{apiVersion}/cards/{cardId}/dependentCards/{dependentCardId}', 'verb' => 'DELETE'], ['name' => 'stack_ocs#create', 'url' => '/api/v{apiVersion}/stacks', 'verb' => 'POST'], ['name' => 'stack_ocs#setDoneStack', 'url' => '/api/v{apiVersion}/stacks/{stackId}/done', 'verb' => 'PUT'], diff --git a/cypress/e2e/cardFeatures.js b/cypress/e2e/cardFeatures.js index fbc08fa9b7..7fa97d846c 100644 --- a/cypress/e2e/cardFeatures.js +++ b/cypress/e2e/cardFeatures.js @@ -205,10 +205,14 @@ describe('Card', function () { cy.reload() cy.get('.modal__card').should('be.visible') + + // Scroll to the bottom to ensure all content is loaded and visible + cy.get('.modal__card .app-sidebar-tabs, .modal__card .app-sidebar__tab--active').first().scrollTo('bottom', { ensureScrollable: false }) + cy.contains('.modal__card .ProseMirror p', 'Paragraph').scrollIntoView().should('be.visible') + cy.get('.modal__card .ProseMirror h1').contains('Hello world writing more text').should('be.visible') cy.get('.modal__card .ProseMirror li').eq(0).contains('List item').should('be.visible') cy.get('.modal__card .ProseMirror li').eq(1).contains('with entries').should('be.visible') - cy.get('.modal__card .ProseMirror p').contains('Paragraph').should('be.visible') }) it('Smart picker', () => { diff --git a/lib/Controller/CardApiController.php b/lib/Controller/CardApiController.php index 87f22dbfda..4bd3fdd2cb 100644 --- a/lib/Controller/CardApiController.php +++ b/lib/Controller/CardApiController.php @@ -149,6 +149,28 @@ public function unassignUser(int $cardId, string $userId, int $type = 0): DataRe return new DataResponse($card, HTTP::STATUS_OK); } + /** + * Assign a dependent card + */ + #[NoAdminRequired] + #[CORS] + #[NoCSRFRequired] + public function assignDependentCard(int $cardId, int $dependentCardId): DataResponse { + $card = $this->cardService->assignDependentCard($cardId, $dependentCardId); + return new DataResponse($card, HTTP::STATUS_OK); + } + + /** + * Remove a dependent card + */ + #[NoAdminRequired] + #[CORS] + #[NoCSRFRequired] + public function removeDependentCard(int $cardId, int $dependentCardId): DataResponse { + $card = $this->cardService->removeDependentCard($cardId, $dependentCardId); + return new DataResponse($card, HTTP::STATUS_OK); + } + /** * Archive card */ diff --git a/lib/Controller/CardController.php b/lib/Controller/CardController.php index b8c80456d9..58e412665d 100644 --- a/lib/Controller/CardController.php +++ b/lib/Controller/CardController.php @@ -128,4 +128,14 @@ public function assignUser(int $cardId, string $userId, int $type = 0): Assignme public function unassignUser(int $cardId, string $userId, int $type = 0): Assignment { return $this->assignmentService->unassignUser($cardId, $userId, $type); } + + #[NoAdminRequired] + public function assignDependentCard(int $cardId, int $dependentCardId): Card { + return $this->cardService->assignDependentCard($cardId, $dependentCardId); + } + + #[NoAdminRequired] + public function removeDependentCard(int $cardId, int $dependentCardId): Card { + return $this->cardService->removeDependentCard($cardId, $dependentCardId); + } } diff --git a/lib/Controller/CardOcsController.php b/lib/Controller/CardOcsController.php index 6805e6bd6e..cb327f8b42 100644 --- a/lib/Controller/CardOcsController.php +++ b/lib/Controller/CardOcsController.php @@ -170,4 +170,28 @@ public function reorder(int $cardId, int $stackId, int $order, ?int $boardId): D } return new DataResponse($this->cardService->reorder($cardId, $stackId, $order)); } + + #[NoAdminRequired] + #[PublicPage] + public function assignDependentCard(int $cardId, int $dependentCardId, ?int $boardId = null): DataResponse { + if ($boardId) { + $board = $this->boardService->find($boardId, false); + if ($board->getExternalId()) { + // External board support can be added later if needed + } + } + return new DataResponse($this->cardService->assignDependentCard($cardId, $dependentCardId)); + } + + #[NoAdminRequired] + #[PublicPage] + public function removeDependentCard(int $cardId, int $dependentCardId, ?int $boardId = null): DataResponse { + if ($boardId) { + $board = $this->boardService->find($boardId, false); + if ($board->getExternalId()) { + // External board support can be added later if needed + } + } + return new DataResponse($this->cardService->removeDependentCard($cardId, $dependentCardId)); + } } diff --git a/lib/Db/Card.php b/lib/Db/Card.php index ad8e7f67d9..2c24e38246 100644 --- a/lib/Db/Card.php +++ b/lib/Db/Card.php @@ -37,6 +37,9 @@ * @method ?DateTime getStartdate() * @method void setStartdate(?DateTime $startdate) * + * @method void setDependentCards(array $cardIds) + * @method null|array getDependentCards() + * * @method void setLabels(Label[] $labels) * @method null|Label[] getLabels() * @@ -90,6 +93,7 @@ class Card extends RelationalEntity { protected $deletedAt = 0; protected $commentsUnread = 0; protected $commentsCount = 0; + protected ?array $dependentCards = null; protected $relatedStack = null; protected $relatedBoard = null; @@ -113,6 +117,7 @@ public function __construct() { $this->addType('deletedAt', 'integer'); $this->addType('duedate', 'datetime'); $this->addType('startdate', 'datetime'); + $this->addType('dependentCards', 'json'); $this->addRelation('labels'); $this->addRelation('assignedUsers'); $this->addRelation('attachments'); diff --git a/lib/Migration/Version11002Date20260410000000.php b/lib/Migration/Version11002Date20260410000000.php new file mode 100644 index 0000000000..646187a84a --- /dev/null +++ b/lib/Migration/Version11002Date20260410000000.php @@ -0,0 +1,29 @@ +hasTable('deck_cards')) { + $table = $schema->getTable('deck_cards'); + if (!$table->hasColumn('dependent_cards')) { + $table->addColumn('dependent_cards', 'text', [ + 'notnull' => false, + ]); + } + } + return $schema; + } +} diff --git a/lib/Service/CardService.php b/lib/Service/CardService.php index 877d212c5b..84b1070bca 100644 --- a/lib/Service/CardService.php +++ b/lib/Service/CardService.php @@ -675,4 +675,71 @@ public function getCardUrl(int $cardId): string { public function getRedirectUrlForCard(int $cardId): string { return $this->urlGenerator->linkToRouteAbsolute('deck.page.redirectToCard', ['cardId' => $cardId]); } + + /** + * @throws StatusException + * @throws \OCA\Deck\NoPermissionException + * @throws \OCP\AppFramework\Db\DoesNotExistException + * @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException + * @throws BadRequestException + */ + public function assignDependentCard(int $cardId, int $dependentCardId): Card { + $this->permissionService->checkPermission($this->cardMapper, $cardId, Acl::PERMISSION_EDIT); + $this->permissionService->checkPermission($this->cardMapper, $dependentCardId, Acl::PERMISSION_READ); + + if ($this->boardService->isArchived($this->cardMapper, $cardId)) { + throw new StatusException('Operation not allowed. This board is archived.'); + } + + $card = $this->cardMapper->find($cardId); + if ($card->getArchived()) { + throw new StatusException('Operation not allowed. This card is archived.'); + } + + $dependentCards = $card->getDependentCards() ?? []; + if (!in_array($dependentCardId, $dependentCards, true)) { + $dependentCards[] = $dependentCardId; + $card->setDependentCards($dependentCards); + $card = $this->cardMapper->update($card); + $this->changeHelper->cardChanged($cardId); + $this->activityManager->triggerEvent(ActivityManager::DECK_OBJECT_CARD, $card, ActivityManager::SUBJECT_CARD_UPDATE); + } + + [$card] = $this->enrichCards([$card]); + return $card; + } + + /** + * @throws StatusException + * @throws \OCA\Deck\NoPermissionException + * @throws \OCP\AppFramework\Db\DoesNotExistException + * @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException + * @throws BadRequestException + */ + public function removeDependentCard(int $cardId, int $dependentCardId): Card { + $this->permissionService->checkPermission($this->cardMapper, $cardId, Acl::PERMISSION_EDIT); + $this->permissionService->checkPermission($this->cardMapper, $dependentCardId, Acl::PERMISSION_READ); + + if ($this->boardService->isArchived($this->cardMapper, $cardId)) { + throw new StatusException('Operation not allowed. This board is archived.'); + } + + $card = $this->cardMapper->find($cardId); + if ($card->getArchived()) { + throw new StatusException('Operation not allowed. This card is archived.'); + } + + $dependentCards = $card->getDependentCards() ?? []; + $key = array_search($dependentCardId, $dependentCards, true); + if ($key !== false) { + unset($dependentCards[$key]); + $card->setDependentCards(array_values($dependentCards)); + $card = $this->cardMapper->update($card); + $this->changeHelper->cardChanged($cardId); + $this->activityManager->triggerEvent(ActivityManager::DECK_OBJECT_CARD, $card, ActivityManager::SUBJECT_CARD_UPDATE); + } + + [$card] = $this->enrichCards([$card]); + return $card; + } } diff --git a/src/components/card/CardSidebarTabDetails.vue b/src/components/card/CardSidebarTabDetails.vue index 3c1d6ec770..c70ebdf077 100644 --- a/src/components/card/CardSidebarTabDetails.vue +++ b/src/components/card/CardSidebarTabDetails.vue @@ -28,6 +28,11 @@ @change="updateCardDue" @input="debouncedUpdateCardDue" /> + +
id !== dependentCardId) + } + + this.$store.dispatch('removeDependentCard', { + card: this.copiedCard, + dependentCardId, + }) + }, stringify(date) { return moment(date).locale(this.locale).format('LLL') }, diff --git a/src/components/card/DependentCardsSelector.vue b/src/components/card/DependentCardsSelector.vue new file mode 100644 index 0000000000..f48247ae5f --- /dev/null +++ b/src/components/card/DependentCardsSelector.vue @@ -0,0 +1,202 @@ + + + + + diff --git a/src/services/CardApi.js b/src/services/CardApi.js index 59cfbea387..9057a738cc 100644 --- a/src/services/CardApi.js +++ b/src/services/CardApi.js @@ -234,4 +234,38 @@ export class CardApi { }) } + assignDependentCard(cardId, dependentCardId, boardId) { + return axios.post(this.ocsUrl(`/cards/${cardId}/dependentCards/${dependentCardId}`), { boardId: boardId ?? null }) + .then( + (response) => { + return Promise.resolve(response.data.ocs.data) + }, + (err) => { + return Promise.reject(err) + }, + ) + .catch((err) => { + return Promise.reject(err) + }) + } + + removeDependentCard(cardId, dependentCardId, boardId) { + return axios.delete(this.ocsUrl(`/cards/${cardId}/dependentCards/${dependentCardId}`), { + data: { + boardId: boardId ?? null, + }, + }) + .then( + (response) => { + return Promise.resolve(response.data.ocs.data) + }, + (err) => { + return Promise.reject(err) + }, + ) + .catch((err) => { + return Promise.reject(err) + }) + } + } diff --git a/src/store/card.js b/src/store/card.js index 85986d8d00..275a4e6891 100644 --- a/src/store/card.js +++ b/src/store/card.js @@ -373,6 +373,16 @@ export default function cardModuleFactory() { await apiClient.removeLabelFromCard(data) commit('updateCardProperty', { property: 'labels', card: data.card }) }, + async assignDependentCard({ commit }, { card, dependentCard }) { + const boardId = this.state.currentBoard.id + const updatedCard = await apiClient.assignDependentCard(card.id, dependentCard.id, boardId) + commit('updateCardProperty', { property: 'dependentCards', card: updatedCard }) + }, + async removeDependentCard({ commit }, { card, dependentCardId }) { + const boardId = this.state.currentBoard.id + const updatedCard = await apiClient.removeDependentCard(card.id, dependentCardId, boardId) + commit('updateCardProperty', { property: 'dependentCards', card: updatedCard }) + }, async updateCardDesc({ commit, getters }, card) { const stack = getters.stackById(card.stackId) const updatedCard = await apiClient.updateCard(card, stack.boardId) diff --git a/tests/data/deck.json b/tests/data/deck.json index 207de16084..0fa4514aef 100644 --- a/tests/data/deck.json +++ b/tests/data/deck.json @@ -108,6 +108,7 @@ "deletedAt": 0, "commentsUnread": 0, "commentsCount": 0, + "dependentCards": null, "ETag": "ddfd0c27e53d8db94ac5e9aaa021746e", "overdue": 0, "boardId": 188, @@ -143,6 +144,7 @@ "deletedAt": 0, "commentsUnread": 0, "commentsCount": 0, + "dependentCards": null, "ETag": "9a8ed495f7d83f8310ae6291d6dc4624", "overdue": 3, "boardId": 188, @@ -190,6 +192,7 @@ "deletedAt": 0, "commentsUnread": 0, "commentsCount": 0, + "dependentCards": null, "ETag": "f908c4359e9ca0703f50da2bbe967594", "overdue": 0, "boardId": 188, @@ -265,6 +268,7 @@ "deletedAt": 0, "commentsUnread": 0, "commentsCount": 0, + "dependentCards": null, "ETag": "6b20cc46fa5d2e5f65251526b50cc130", "overdue": 0, "boardId": 188, @@ -310,6 +314,7 @@ "deletedAt": 0, "commentsUnread": 0, "commentsCount": 0, + "dependentCards": null, "ETag": "488145982535a91d9ab47db647ecf539", "overdue": 0, "boardId": 188, @@ -357,6 +362,7 @@ "deletedAt": 0, "commentsUnread": 0, "commentsCount": 0, + "dependentCards": null, "ETag": "b97a2b19e1cafc8f95e3f4db71097214", "overdue": 0, "boardId": 188, @@ -531,6 +537,7 @@ "deletedAt": 0, "commentsUnread": 0, "commentsCount": 0, + "dependentCards": null, "ETag": "f0450d41827f55580554c993304c8073", "overdue": 0, "boardId": 189, @@ -566,6 +573,7 @@ "deletedAt": 0, "commentsUnread": 0, "commentsCount": 0, + "dependentCards": null, "ETag": "f0450d41827f55580554c993304c8073", "overdue": 0, "boardId": 189, @@ -601,6 +609,7 @@ "deletedAt": 0, "commentsUnread": 0, "commentsCount": 0, + "dependentCards": null, "ETag": "1956848c45be91fefc967ee8831ea4cf", "overdue": 0, "boardId": 189, @@ -636,6 +645,7 @@ "deletedAt": 0, "commentsUnread": 0, "commentsCount": 0, + "dependentCards": null, "ETag": "6c315c83f146485e6b2b6fdc24ffa617", "overdue": 0, "boardId": 189, @@ -683,6 +693,7 @@ "deletedAt": 0, "commentsUnread": 0, "commentsCount": 0, + "dependentCards": null, "ETag": "d2a8b634cdd96ab5ef48910bbbd715b1", "overdue": 0, "boardId": 189, @@ -730,6 +741,7 @@ "deletedAt": 0, "commentsUnread": 0, "commentsCount": 0, + "dependentCards": null, "ETag": "193163d8a8acedbfaba196b1f0d65bc8", "overdue": 0, "boardId": 189, @@ -765,6 +777,7 @@ "deletedAt": 0, "commentsUnread": 0, "commentsCount": 0, + "dependentCards": null, "ETag": "193163d8a8acedbfaba196b1f0d65bc8", "overdue": 0, "boardId": 189, diff --git a/tests/unit/Db/CardTest.php b/tests/unit/Db/CardTest.php index 6e06b81017..03170f11ab 100644 --- a/tests/unit/Db/CardTest.php +++ b/tests/unit/Db/CardTest.php @@ -44,6 +44,7 @@ private function createCard() { $card->setArchived(false); $card->setDone(null); $card->setColor('ffffff'); + $card->setDependentCards([2, 3]); // TODO: relation shared labels acl return $card; } @@ -94,6 +95,7 @@ public function testJsonSerialize() { 'attachments' => [], 'attachmentCount' => 0, 'assignedUsers' => null, + 'dependentCards' => [2, 3], 'deletedAt' => 0, 'commentsUnread' => 0, 'commentsCount' => 0, @@ -125,6 +127,7 @@ public function testJsonSerializeLabels() { 'attachments' => [], 'attachmentCount' => 0, 'assignedUsers' => null, + 'dependentCards' => [2, 3], 'deletedAt' => 0, 'commentsUnread' => 0, 'commentsCount' => 0, @@ -158,6 +161,7 @@ public function testJsonSerializeAsignedUsers() { 'attachments' => [], 'attachmentCount' => 0, 'assignedUsers' => ['user1'], + 'dependentCards' => [2, 3], 'deletedAt' => 0, 'commentsUnread' => 0, 'commentsCount' => 0, diff --git a/tests/unit/Service/CardServiceTest.php b/tests/unit/Service/CardServiceTest.php index b6299b17ac..1bd05ac5a3 100644 --- a/tests/unit/Service/CardServiceTest.php +++ b/tests/unit/Service/CardServiceTest.php @@ -633,4 +633,50 @@ public function testDoneDoesNotMoveCardAlreadyInDoneColumn(): void { $this->assertNotNull($result->getDone()); $this->assertEquals(20, $result->getStackId()); } + + public function testAssignDependentCard() { + $card = Card::fromParams([ + 'title' => 'Card title', + 'stackId' => 234, + 'dependentCards' => [44] + ]); + $stack = Stack::fromParams([ + 'id' => 234, + 'boardId' => 1337, + ]); + $this->cardMapper->expects($this->once())->method('find')->willReturn($card); + $this->cardMapper->expects($this->once())->method('update')->willReturnCallback(function ($c) { + $c->setId(1); + return $c; + }); + $this->stackMapper->expects($this->once()) + ->method('find') + ->with(234) + ->willReturn($stack); + $result = $this->cardService->assignDependentCard(42, 43); + $this->assertEquals([44, 43], $result->getDependentCards()); + } + + public function testRemoveDependentCard() { + $card = Card::fromParams([ + 'title' => 'Card title', + 'stackId' => 234, + 'dependentCards' => [44, 43] + ]); + $stack = Stack::fromParams([ + 'id' => 234, + 'boardId' => 1337, + ]); + $this->cardMapper->expects($this->once())->method('find')->willReturn($card); + $this->cardMapper->expects($this->once())->method('update')->willReturnCallback(function ($c) { + $c->setId(1); + return $c; + }); + $this->stackMapper->expects($this->once()) + ->method('find') + ->with(234) + ->willReturn($stack); + $result = $this->cardService->removeDependentCard(42, 43); + $this->assertEquals([44], $result->getDependentCards()); + } }