Skip to content

Commit b40148b

Browse files
feat: add card dependencies
Signed-off-by: Luka Trovic <luka@nextcloud.com>
1 parent 9a89470 commit b40148b

15 files changed

Lines changed: 517 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: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,4 +170,28 @@ public function reorder(int $cardId, int $stackId, int $order, ?int $boardId): D
170170
}
171171
return new DataResponse($this->cardService->reorder($cardId, $stackId, $order));
172172
}
173+
174+
#[NoAdminRequired]
175+
#[PublicPage]
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+
public function removeDependentCard(int $cardId, int $dependentCardId, ?int $boardId = null): DataResponse {
189+
if ($boardId) {
190+
$board = $this->boardService->find($boardId, false);
191+
if ($board->getExternalId()) {
192+
// External board support can be added later if needed
193+
}
194+
}
195+
return new DataResponse($this->cardService->removeDependentCard($cardId, $dependentCardId));
196+
}
173197
}

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->addType('dependentCards', 'json');
116121
$this->addRelation('labels');
117122
$this->addRelation('assignedUsers');
118123
$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', 'text', [
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
@@ -675,4 +675,71 @@ public function getCardUrl(int $cardId): string {
675675
public function getRedirectUrlForCard(int $cardId): string {
676676
return $this->urlGenerator->linkToRouteAbsolute('deck.page.redirectToCard', ['cardId' => $cardId]);
677677
}
678+
679+
/**
680+
* @throws StatusException
681+
* @throws \OCA\Deck\NoPermissionException
682+
* @throws \OCP\AppFramework\Db\DoesNotExistException
683+
* @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException
684+
* @throws BadRequestException
685+
*/
686+
public function assignDependentCard(int $cardId, int $dependentCardId): Card {
687+
$this->permissionService->checkPermission($this->cardMapper, $cardId, Acl::PERMISSION_EDIT);
688+
$this->permissionService->checkPermission($this->cardMapper, $dependentCardId, Acl::PERMISSION_READ);
689+
690+
if ($this->boardService->isArchived($this->cardMapper, $cardId)) {
691+
throw new StatusException('Operation not allowed. This board is archived.');
692+
}
693+
694+
$card = $this->cardMapper->find($cardId);
695+
if ($card->getArchived()) {
696+
throw new StatusException('Operation not allowed. This card is archived.');
697+
}
698+
699+
$dependentCards = $card->getDependentCards() ?? [];
700+
if (!in_array($dependentCardId, $dependentCards, true)) {
701+
$dependentCards[] = $dependentCardId;
702+
$card->setDependentCards($dependentCards);
703+
$card = $this->cardMapper->update($card);
704+
$this->changeHelper->cardChanged($cardId);
705+
$this->activityManager->triggerEvent(ActivityManager::DECK_OBJECT_CARD, $card, ActivityManager::SUBJECT_CARD_UPDATE);
706+
}
707+
708+
[$card] = $this->enrichCards([$card]);
709+
return $card;
710+
}
711+
712+
/**
713+
* @throws StatusException
714+
* @throws \OCA\Deck\NoPermissionException
715+
* @throws \OCP\AppFramework\Db\DoesNotExistException
716+
* @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException
717+
* @throws BadRequestException
718+
*/
719+
public function removeDependentCard(int $cardId, int $dependentCardId): Card {
720+
$this->permissionService->checkPermission($this->cardMapper, $cardId, Acl::PERMISSION_EDIT);
721+
$this->permissionService->checkPermission($this->cardMapper, $dependentCardId, Acl::PERMISSION_READ);
722+
723+
if ($this->boardService->isArchived($this->cardMapper, $cardId)) {
724+
throw new StatusException('Operation not allowed. This board is archived.');
725+
}
726+
727+
$card = $this->cardMapper->find($cardId);
728+
if ($card->getArchived()) {
729+
throw new StatusException('Operation not allowed. This card is archived.');
730+
}
731+
732+
$dependentCards = $card->getDependentCards() ?? [];
733+
$key = array_search($dependentCardId, $dependentCards, true);
734+
if ($key !== false) {
735+
unset($dependentCards[$key]);
736+
$card->setDependentCards(array_values($dependentCards));
737+
$card = $this->cardMapper->update($card);
738+
$this->changeHelper->cardChanged($cardId);
739+
$this->activityManager->triggerEvent(ActivityManager::DECK_OBJECT_CARD, $card, ActivityManager::SUBJECT_CARD_UPDATE);
740+
}
741+
742+
[$card] = $this->enrichCards([$card]);
743+
return $card;
744+
}
678745
}

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)