Skip to content

Commit 9313cda

Browse files
feat: add card dependencies
Signed-off-by: Luka Trovic <luka@nextcloud.com>
1 parent b7af6d5 commit 9313cda

16 files changed

Lines changed: 654 additions & 1 deletion

File tree

appinfo/routes.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@
5757
['name' => 'card#removeLabel', 'url' => '/cards/{cardId}/label/{labelId}', 'verb' => 'DELETE'],
5858
['name' => 'card#assignUser', 'url' => '/cards/{cardId}/assign', 'verb' => 'POST'],
5959
['name' => 'card#unassignUser', 'url' => '/cards/{cardId}/unassign', 'verb' => 'PUT'],
60+
['name' => 'card#assignDependentCard', 'url' => '/cards/{cardId}/dependentCards/{dependentCardId}', 'verb' => 'POST'],
61+
['name' => 'card#removeDependentCard', 'url' => '/cards/{cardId}/dependentCards/{dependentCardId}', 'verb' => 'DELETE'],
6062

6163
// attachments
6264
['name' => 'attachment#getAll', 'url' => '/cards/{cardId}/attachments', 'verb' => 'GET'],
@@ -105,6 +107,8 @@
105107
['name' => 'card_api#assignUser', 'url' => '/api/v{apiVersion}/boards/{boardId}/stacks/{stackId}/cards/{cardId}/assignUser', 'verb' => 'PUT'],
106108
['name' => 'card_api#unassignUser', 'url' => '/api/v{apiVersion}/boards/{boardId}/stacks/{stackId}/cards/{cardId}/unassignUser', 'verb' => 'PUT'],
107109
['name' => 'card_api#reorder', 'url' => '/api/v{apiVersion}/boards/{boardId}/stacks/{stackId}/cards/{cardId}/reorder', 'verb' => 'PUT'],
110+
['name' => 'card_api#assignDependentCard', 'url' => '/api/v{apiVersion}/boards/{boardId}/stacks/{stackId}/cards/{cardId}/dependentCards/{dependentCardId}', 'verb' => 'POST'],
111+
['name' => 'card_api#removeDependentCard', 'url' => '/api/v{apiVersion}/boards/{boardId}/stacks/{stackId}/cards/{cardId}/dependentCards/{dependentCardId}', 'verb' => 'DELETE'],
108112
['name' => 'card_api#archive', 'url' => '/api/v{apiVersion}/boards/{boardId}/stacks/{stackId}/cards/{cardId}/archive', 'verb' => 'PUT'],
109113
['name' => 'card_api#unarchive', 'url' => '/api/v{apiVersion}/boards/{boardId}/stacks/{stackId}/cards/{cardId}/unarchive', 'verb' => 'PUT'],
110114
['name' => 'card_api#delete', 'url' => '/api/v{apiVersion}/boards/{boardId}/stacks/{stackId}/cards/{cardId}', 'verb' => 'DELETE'],
@@ -146,6 +150,8 @@
146150
['name' => 'card_ocs#unAssignUser', 'url' => '/api/v{apiVersion}/cards/{cardId}/unassign', 'verb' => 'PUT'],
147151
['name' => 'card_ocs#removeLabel', 'url' => '/api/v{apiVersion}/cards/{cardId}/label/{labelId}', 'verb' => 'DELETE'],
148152
['name' => 'card_ocs#reorder', 'url' => '/api/v{apiVersion}/cards/{cardId}/reorder', 'verb' => 'PUT'],
153+
['name' => 'card_ocs#assignDependentCard', 'url' => '/api/v{apiVersion}/cards/{cardId}/dependentCards/{dependentCardId}', 'verb' => 'POST'],
154+
['name' => 'card_ocs#removeDependentCard', 'url' => '/api/v{apiVersion}/cards/{cardId}/dependentCards/{dependentCardId}', 'verb' => 'DELETE'],
149155

150156
['name' => 'stack_ocs#create', 'url' => '/api/v{apiVersion}/stacks', 'verb' => 'POST'],
151157
['name' => 'stack_ocs#setDoneStack', 'url' => '/api/v{apiVersion}/stacks/{stackId}/done', 'verb' => 'PUT'],

cypress/e2e/cardFeatures.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,10 +205,14 @@ describe('Card', function () {
205205

206206
cy.reload()
207207
cy.get('.modal__card').should('be.visible')
208+
209+
// Scroll to the bottom to ensure all content is loaded and visible
210+
cy.get('.modal__card .app-sidebar-tabs, .modal__card .app-sidebar__tab--active').first().scrollTo('bottom', { ensureScrollable: false })
211+
cy.contains('.modal__card .ProseMirror p', 'Paragraph').scrollIntoView().should('be.visible')
212+
208213
cy.get('.modal__card .ProseMirror h1').contains('Hello world writing more text').should('be.visible')
209214
cy.get('.modal__card .ProseMirror li').eq(0).contains('List item').should('be.visible')
210215
cy.get('.modal__card .ProseMirror li').eq(1).contains('with entries').should('be.visible')
211-
cy.get('.modal__card .ProseMirror p').contains('Paragraph').should('be.visible')
212216
})
213217

214218
it('Smart picker', () => {

lib/Controller/CardApiController.php

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,28 @@ public function unassignUser(int $cardId, string $userId, int $type = 0): DataRe
149149
return new DataResponse($card, HTTP::STATUS_OK);
150150
}
151151

152+
/**
153+
* Assign a dependent card
154+
*/
155+
#[NoAdminRequired]
156+
#[CORS]
157+
#[NoCSRFRequired]
158+
public function assignDependentCard(int $cardId, int $dependentCardId): DataResponse {
159+
$card = $this->cardService->assignDependentCard($cardId, $dependentCardId);
160+
return new DataResponse($card, HTTP::STATUS_OK);
161+
}
162+
163+
/**
164+
* Remove a dependent card
165+
*/
166+
#[NoAdminRequired]
167+
#[CORS]
168+
#[NoCSRFRequired]
169+
public function removeDependentCard(int $cardId, int $dependentCardId): DataResponse {
170+
$card = $this->cardService->removeDependentCard($cardId, $dependentCardId);
171+
return new DataResponse($card, HTTP::STATUS_OK);
172+
}
173+
152174
/**
153175
* Archive card
154176
*/

lib/Controller/CardController.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,4 +128,14 @@ public function assignUser(int $cardId, string $userId, int $type = 0): Assignme
128128
public function unassignUser(int $cardId, string $userId, int $type = 0): Assignment {
129129
return $this->assignmentService->unassignUser($cardId, $userId, $type);
130130
}
131+
132+
#[NoAdminRequired]
133+
public function assignDependentCard(int $cardId, int $dependentCardId): Card {
134+
return $this->cardService->assignDependentCard($cardId, $dependentCardId);
135+
}
136+
137+
#[NoAdminRequired]
138+
public function removeDependentCard(int $cardId, int $dependentCardId): Card {
139+
return $this->cardService->removeDependentCard($cardId, $dependentCardId);
140+
}
131141
}

lib/Controller/CardOcsController.php

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
namespace OCA\Deck\Controller;
99

1010
use OCA\Deck\Model\OptionalNullableValue;
11+
use OCA\Deck\NotImplementedException;
1112
use OCA\Deck\Service\AssignmentService;
1213
use OCA\Deck\Service\BoardService;
1314
use OCA\Deck\Service\CardService;
@@ -170,4 +171,28 @@ public function reorder(int $cardId, int $stackId, int $order, ?int $boardId): D
170171
}
171172
return new DataResponse($this->cardService->reorder($cardId, $stackId, $order));
172173
}
174+
175+
#[NoAdminRequired]
176+
#[PublicPage]
177+
public function assignDependentCard(int $cardId, int $dependentCardId, ?int $boardId = null): DataResponse {
178+
if ($boardId) {
179+
$board = $this->boardService->find($boardId, false);
180+
if ($board->getExternalId()) {
181+
throw new NotImplementedException('Dependent cards are not supported for external boards');
182+
}
183+
}
184+
return new DataResponse($this->cardService->assignDependentCard($cardId, $dependentCardId));
185+
}
186+
187+
#[NoAdminRequired]
188+
#[PublicPage]
189+
public function removeDependentCard(int $cardId, int $dependentCardId, ?int $boardId = null): DataResponse {
190+
if ($boardId) {
191+
$board = $this->boardService->find($boardId, false);
192+
if ($board->getExternalId()) {
193+
throw new NotImplementedException('Dependent cards are not supported for external boards');
194+
}
195+
}
196+
return new DataResponse($this->cardService->removeDependentCard($cardId, $dependentCardId));
197+
}
173198
}

lib/Db/Card.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@
3737
* @method ?DateTime getStartdate()
3838
* @method void setStartdate(?DateTime $startdate)
3939
*
40+
* @method void setDependentCards(array $cardIds)
41+
* @method null|array getDependentCards()
42+
*
4043
* @method void setLabels(Label[] $labels)
4144
* @method null|Label[] getLabels()
4245
*
@@ -90,6 +93,7 @@ class Card extends RelationalEntity {
9093
protected $deletedAt = 0;
9194
protected $commentsUnread = 0;
9295
protected $commentsCount = 0;
96+
protected ?array $dependentCards = null;
9397

9498
protected $relatedStack = null;
9599
protected $relatedBoard = null;
@@ -113,6 +117,7 @@ public function __construct() {
113117
$this->addType('deletedAt', 'integer');
114118
$this->addType('duedate', 'datetime');
115119
$this->addType('startdate', 'datetime');
120+
$this->addRelation('dependentCards');
116121
$this->addRelation('labels');
117122
$this->addRelation('assignedUsers');
118123
$this->addRelation('attachments');

lib/Db/CardMapper.php

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -614,6 +614,7 @@ public function searchRaw($boardIds, $term, $limit = null, $offset = null) {
614614

615615
public function delete(Entity $entity): Entity {
616616
$this->labelMapper->deleteLabelAssignmentsForCard($entity->getId());
617+
$this->removeDependenciesForCard($entity->getId());
617618
$this->cache->remove('findBoardId:' . $entity->getId());
618619
return parent::delete($entity);
619620
}
@@ -643,6 +644,76 @@ public function removeLabel(int $card, int $label): void {
643644
$qb->executeStatement();
644645
}
645646

647+
/**
648+
* @param int[] $cardIds
649+
* @return array<int, int[]>
650+
*/
651+
public function findDependenciesForCards(array $cardIds): array {
652+
if ($cardIds === []) {
653+
return [];
654+
}
655+
656+
$qb = $this->db->getQueryBuilder();
657+
$qb->select('card_id', 'dependent_card_id')
658+
->from('deck_dependent_cards')
659+
->where($qb->expr()->in('card_id', $qb->createNamedParameter($cardIds, IQueryBuilder::PARAM_INT_ARRAY)))
660+
->orderBy('card_id')
661+
->addOrderBy('dependent_card_id');
662+
663+
$result = [];
664+
$queryResult = $qb->executeQuery();
665+
while ($row = $queryResult->fetch()) {
666+
$cardId = (int)$row['card_id'];
667+
$result[$cardId][] = (int)$row['dependent_card_id'];
668+
}
669+
670+
return $result;
671+
}
672+
673+
public function addDependency(int $cardId, int $dependentCardId): bool {
674+
if ($this->hasDependency($cardId, $dependentCardId)) {
675+
return false;
676+
}
677+
678+
$qb = $this->db->getQueryBuilder();
679+
$qb->insert('deck_dependent_cards')
680+
->values([
681+
'card_id' => $qb->createNamedParameter($cardId, IQueryBuilder::PARAM_INT),
682+
'dependent_card_id' => $qb->createNamedParameter($dependentCardId, IQueryBuilder::PARAM_INT),
683+
]);
684+
$qb->executeStatement();
685+
686+
return true;
687+
}
688+
689+
public function removeDependency(int $cardId, int $dependentCardId): bool {
690+
$qb = $this->db->getQueryBuilder();
691+
$qb->delete('deck_dependent_cards')
692+
->where($qb->expr()->eq('card_id', $qb->createNamedParameter($cardId, IQueryBuilder::PARAM_INT)))
693+
->andWhere($qb->expr()->eq('dependent_card_id', $qb->createNamedParameter($dependentCardId, IQueryBuilder::PARAM_INT)));
694+
695+
return $qb->executeStatement() > 0;
696+
}
697+
698+
public function removeDependenciesForCard(int $cardId): void {
699+
$qb = $this->db->getQueryBuilder();
700+
$qb->delete('deck_dependent_cards')
701+
->where($qb->expr()->eq('card_id', $qb->createNamedParameter($cardId, IQueryBuilder::PARAM_INT)))
702+
->orWhere($qb->expr()->eq('dependent_card_id', $qb->createNamedParameter($cardId, IQueryBuilder::PARAM_INT)));
703+
$qb->executeStatement();
704+
}
705+
706+
private function hasDependency(int $cardId, int $dependentCardId): bool {
707+
$qb = $this->db->getQueryBuilder();
708+
$qb->select('id')
709+
->from('deck_dependent_cards')
710+
->where($qb->expr()->eq('card_id', $qb->createNamedParameter($cardId, IQueryBuilder::PARAM_INT)))
711+
->andWhere($qb->expr()->eq('dependent_card_id', $qb->createNamedParameter($dependentCardId, IQueryBuilder::PARAM_INT)))
712+
->setMaxResults(1);
713+
714+
return $qb->executeQuery()->fetchOne() !== false;
715+
}
716+
646717
public function isOwner(string $userId, int $id): bool {
647718
$qb = $this->db->getQueryBuilder();
648719
$qb->select('c.id')
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<?php
2+
3+
/**
4+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
5+
* SPDX-License-Identifier: AGPL-3.0-or-later
6+
*/
7+
8+
declare(strict_types=1);
9+
namespace OCA\Deck\Migration;
10+
11+
use Closure;
12+
use OCP\Migration\IOutput;
13+
use OCP\Migration\SimpleMigrationStep;
14+
15+
class Version11002Date20260410000000 extends SimpleMigrationStep {
16+
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options) {
17+
$schema = $schemaClosure();
18+
19+
if (!$schema->hasTable('deck_dependent_cards')) {
20+
$table = $schema->createTable('deck_dependent_cards');
21+
$table->addColumn('id', 'integer', [
22+
'autoincrement' => true,
23+
'notnull' => true,
24+
'length' => 4,
25+
]);
26+
$table->addColumn('card_id', 'integer', [
27+
'notnull' => true,
28+
'length' => 4,
29+
'default' => 0,
30+
]);
31+
$table->addColumn('dependent_card_id', 'integer', [
32+
'notnull' => true,
33+
'length' => 4,
34+
'default' => 0,
35+
]);
36+
$table->setPrimaryKey(['id']);
37+
$table->addUniqueIndex(['card_id', 'dependent_card_id'], 'deck_depend_cards_uidx');
38+
$table->addIndex(['card_id'], 'deck_depend_cards_idx_c');
39+
$table->addIndex(['dependent_card_id'], 'deck_depend_cards_idx_d');
40+
}
41+
return $schema;
42+
}
43+
}

lib/Service/CardService.php

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ public function enrichCards(array $cards): array {
100100

101101
$assignedLabels = $this->labelMapper->findAssignedLabelsForCards($cardIds);
102102
$assignedUsers = $this->assignedUsersMapper->findIn($cardIds);
103+
$dependenciesByCard = $this->cardMapper->findDependenciesForCards($cardIds);
103104

104105
// Pre-group labels and users by card ID
105106
$labelsByCard = [];
@@ -114,6 +115,7 @@ public function enrichCards(array $cards): array {
114115
foreach ($cards as $card) {
115116
$card->setLabels($labelsByCard[$card->getId()] ?? []);
116117
$card->setAssignedUsers($usersByCard[$card->getId()] ?? []);
118+
$card->setDependentCards($dependenciesByCard[$card->getId()] ?? []);
117119
}
118120

119121
return array_map(
@@ -675,4 +677,62 @@ public function getCardUrl(int $cardId): string {
675677
public function getRedirectUrlForCard(int $cardId): string {
676678
return $this->urlGenerator->linkToRouteAbsolute('deck.page.redirectToCard', ['cardId' => $cardId]);
677679
}
680+
681+
/**
682+
* @throws StatusException
683+
* @throws \OCA\Deck\NoPermissionException
684+
* @throws \OCP\AppFramework\Db\DoesNotExistException
685+
* @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException
686+
* @throws BadRequestException
687+
*/
688+
public function assignDependentCard(int $cardId, int $dependentCardId): Card {
689+
$this->permissionService->checkPermission($this->cardMapper, $cardId, Acl::PERMISSION_EDIT);
690+
$this->permissionService->checkPermission($this->cardMapper, $dependentCardId, Acl::PERMISSION_READ);
691+
692+
if ($this->boardService->isArchived($this->cardMapper, $cardId)) {
693+
throw new StatusException('Operation not allowed. This board is archived.');
694+
}
695+
696+
$card = $this->cardMapper->find($cardId);
697+
if ($card->getArchived()) {
698+
throw new StatusException('Operation not allowed. This card is archived.');
699+
}
700+
701+
if ($this->cardMapper->addDependency($cardId, $dependentCardId)) {
702+
$this->changeHelper->cardChanged($cardId);
703+
$this->activityManager->triggerEvent(ActivityManager::DECK_OBJECT_CARD, $card, ActivityManager::SUBJECT_CARD_UPDATE);
704+
}
705+
706+
[$card] = $this->enrichCards([$card]);
707+
return $card;
708+
}
709+
710+
/**
711+
* @throws StatusException
712+
* @throws \OCA\Deck\NoPermissionException
713+
* @throws \OCP\AppFramework\Db\DoesNotExistException
714+
* @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException
715+
* @throws BadRequestException
716+
*/
717+
public function removeDependentCard(int $cardId, int $dependentCardId): Card {
718+
$this->permissionService->checkPermission($this->cardMapper, $cardId, Acl::PERMISSION_EDIT);
719+
$this->permissionService->checkPermission($this->cardMapper, $dependentCardId, Acl::PERMISSION_READ);
720+
721+
if ($this->boardService->isArchived($this->cardMapper, $cardId)) {
722+
throw new StatusException('Operation not allowed. This board is archived.');
723+
}
724+
725+
$card = $this->cardMapper->find($cardId);
726+
if ($card->getArchived()) {
727+
throw new StatusException('Operation not allowed. This card is archived.');
728+
}
729+
730+
if ($this->cardMapper->removeDependency($cardId, $dependentCardId)) {
731+
$this->changeHelper->cardChanged($cardId);
732+
$this->activityManager->triggerEvent(ActivityManager::DECK_OBJECT_CARD, $card, ActivityManager::SUBJECT_CARD_UPDATE);
733+
}
734+
735+
[$card] = $this->enrichCards([$card]);
736+
return $card;
737+
}
678738
}

0 commit comments

Comments
 (0)