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" />
+