Skip to content

Commit 94af509

Browse files
feat: add card dependencies
Signed-off-by: Luka Trovic <luka@nextcloud.com>
1 parent aad6858 commit 94af509

13 files changed

Lines changed: 502 additions & 0 deletions

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

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: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,4 +169,30 @@ public function reorder(int $cardId, int $stackId, int $order, ?int $boardId): D
169169
}
170170
return new DataResponse($this->cardService->reorder($cardId, $stackId, $order));
171171
}
172+
173+
#[NoAdminRequired]
174+
#[PublicPage]
175+
#[NoCSRFRequired]
176+
public function assignDependentCard(int $cardId, int $dependentCardId, ?int $boardId = null): DataResponse {
177+
if ($boardId) {
178+
$board = $this->boardService->find($boardId, false);
179+
if ($board->getExternalId()) {
180+
// External board support can be added later if needed
181+
}
182+
}
183+
return new DataResponse($this->cardService->assignDependentCard($cardId, $dependentCardId));
184+
}
185+
186+
#[NoAdminRequired]
187+
#[PublicPage]
188+
#[NoCSRFRequired]
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+
// External board support can be added later if needed
194+
}
195+
}
196+
return new DataResponse($this->cardService->removeDependentCard($cardId, $dependentCardId));
197+
}
172198
}

lib/Db/Card.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
use DateTime;
1313
use DateTimeZone;
14+
use OCP\DB\Types;
1415
use Sabre\VObject\Component\VCalendar;
1516

1617
/**
@@ -35,6 +36,9 @@
3536
* @method ?DateTime getStartdate()
3637
* @method void setStartdate(?DateTime $startdate)
3738
*
39+
* @method void setDependentCards(array $cardIds)
40+
* @method null|array getDependentCards()
41+
*
3842
* @method void setLabels(Label[] $labels)
3943
* @method null|Label[] getLabels()
4044
*
@@ -87,6 +91,7 @@ class Card extends RelationalEntity {
8791
protected $deletedAt = 0;
8892
protected $commentsUnread = 0;
8993
protected $commentsCount = 0;
94+
protected ?array $dependentCards = null;
9095

9196
protected $relatedStack = null;
9297
protected $relatedBoard = null;
@@ -110,6 +115,7 @@ public function __construct() {
110115
$this->addType('deletedAt', 'integer');
111116
$this->addType('duedate', 'datetime');
112117
$this->addType('startdate', 'datetime');
118+
$this->addType('dependentCards', Types::JSON);
113119
$this->addRelation('labels');
114120
$this->addRelation('assignedUsers');
115121
$this->addRelation('attachments');
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
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_cards')) {
20+
$table = $schema->getTable('deck_cards');
21+
if (!$table->hasColumn('dependent_cards')) {
22+
$table->addColumn('dependent_cards', 'json', [
23+
'notnull' => false,
24+
]);
25+
}
26+
}
27+
return $schema;
28+
}
29+
}

lib/Service/CardService.php

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -673,4 +673,71 @@ public function getCardUrl(int $cardId): string {
673673
public function getRedirectUrlForCard(int $cardId): string {
674674
return $this->urlGenerator->linkToRouteAbsolute('deck.page.redirectToCard', ['cardId' => $cardId]);
675675
}
676+
677+
/**
678+
* @throws StatusException
679+
* @throws \OCA\Deck\NoPermissionException
680+
* @throws \OCP\AppFramework\Db\DoesNotExistException
681+
* @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException
682+
* @throws BadRequestException
683+
*/
684+
public function assignDependentCard(int $cardId, int $dependentCardId): Card {
685+
$this->permissionService->checkPermission($this->cardMapper, $cardId, Acl::PERMISSION_EDIT);
686+
$this->permissionService->checkPermission($this->cardMapper, $dependentCardId, Acl::PERMISSION_READ);
687+
688+
if ($this->boardService->isArchived($this->cardMapper, $cardId)) {
689+
throw new StatusException('Operation not allowed. This board is archived.');
690+
}
691+
692+
$card = $this->cardMapper->find($cardId);
693+
if ($card->getArchived()) {
694+
throw new StatusException('Operation not allowed. This card is archived.');
695+
}
696+
697+
$dependentCards = $card->getDependentCards() ?? [];
698+
if (!in_array($dependentCardId, $dependentCards, true)) {
699+
$dependentCards[] = $dependentCardId;
700+
$card->setDependentCards($dependentCards);
701+
$card = $this->cardMapper->update($card);
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+
$dependentCards = $card->getDependentCards() ?? [];
731+
$key = array_search($dependentCardId, $dependentCards, true);
732+
if ($key !== false) {
733+
unset($dependentCards[$key]);
734+
$card->setDependentCards(array_values($dependentCards));
735+
$card = $this->cardMapper->update($card);
736+
$this->changeHelper->cardChanged($cardId);
737+
$this->activityManager->triggerEvent(ActivityManager::DECK_OBJECT_CARD, $card, ActivityManager::SUBJECT_CARD_UPDATE);
738+
}
739+
740+
[$card] = $this->enrichCards([$card]);
741+
return $card;
742+
}
676743
}

src/components/card/CardSidebarTabDetails.vue

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@
2828
@change="updateCardDue"
2929
@input="debouncedUpdateCardDue" />
3030

31+
<DependentCardsSelector :card="card"
32+
:can-edit="canEdit"
33+
@select="assignDependentCard"
34+
@remove="removeDependentCard" />
35+
3136
<div v-if="projectsEnabled" class="section-wrapper">
3237
<NcCollectionList v-if="card.id"
3338
:id="`${card.id}`"
@@ -59,10 +64,12 @@ import AssignmentSelector from './AssignmentSelector.vue'
5964
import DueDateSelector from './DueDateSelector.vue'
6065
import StartDateSelector from './StartDateSelector.vue'
6166
import { debounce } from 'lodash'
67+
import DependentCardsSelector from './DependentCardsSelector.vue'
6268
6369
export default {
6470
name: 'CardSidebarTabDetails',
6571
components: {
72+
DependentCardsSelector,
6673
DueDateSelector,
6774
StartDateSelector,
6875
AssignmentSelector,
@@ -203,6 +210,39 @@ export default {
203210
}
204211
this.$store.dispatch('removeLabel', data)
205212
},
213+
assignDependentCard(dependentCard) {
214+
if (!dependentCard?.id) {
215+
return
216+
}
217+
218+
if (!Array.isArray(this.copiedCard.dependentCards)) {
219+
this.copiedCard.dependentCards = []
220+
}
221+
222+
if (!this.copiedCard.dependentCards.includes(dependentCard.id)) {
223+
this.copiedCard.dependentCards.push(dependentCard.id)
224+
}
225+
226+
this.$store.dispatch('assignDependentCard', {
227+
card: this.copiedCard,
228+
dependentCard,
229+
})
230+
},
231+
removeDependentCard(dependentCard) {
232+
const dependentCardId = dependentCard?.id
233+
if (!dependentCardId) {
234+
return
235+
}
236+
237+
if (Array.isArray(this.copiedCard.dependentCards)) {
238+
this.copiedCard.dependentCards = this.copiedCard.dependentCards.filter((id) => id !== dependentCardId)
239+
}
240+
241+
this.$store.dispatch('removeDependentCard', {
242+
card: this.copiedCard,
243+
dependentCardId,
244+
})
245+
},
206246
stringify(date) {
207247
return moment(date).locale(this.locale).format('LLL')
208248
},

0 commit comments

Comments
 (0)