From bff603156bf9e80f96b3042f624ccd932c756254 Mon Sep 17 00:00:00 2001 From: Anna Larch Date: Mon, 2 Mar 2026 19:48:46 +0100 Subject: [PATCH 1/2] feat: allow automatic setting of 'done' status Fixes https://github.com/nextcloud/deck/issues/5485 - Marking a column to set cards as done when added to it Signed-off-by: Anna Larch --- appinfo/routes.php | 3 +- lib/Capabilities.php | 9 +- lib/Controller/BoardOcsController.php | 16 - lib/Controller/StackController.php | 1 + lib/Controller/StackOcsController.php | 27 + lib/Db/Stack.php | 4 + lib/Db/StackMapper.php | 30 + .../Version11002Date20260228000000.php | 30 + lib/Service/CardService.php | 23 + lib/Service/ExternalBoardService.php | 33 + lib/Service/StackService.php | 33 + src/components/board/Stack.vue | 55 +- src/components/cards/CardMenuEntries.vue | 4 + src/services/StackApi.js | 15 + src/store/card.js | 13 +- src/store/stack.js | 18 + tests/data/deck.json | 1512 +++++++++-------- .../features/bootstrap/BoardContext.php | 148 ++ .../integration/features/done-column.feature | 99 ++ tests/unit/Db/StackTest.php | 2 + tests/unit/Service/CardServiceTest.php | 86 + tests/unit/Service/StackServiceTest.php | 50 +- 22 files changed, 1429 insertions(+), 782 deletions(-) create mode 100644 lib/Migration/Version11002Date20260228000000.php create mode 100644 tests/integration/features/done-column.feature diff --git a/appinfo/routes.php b/appinfo/routes.php index 9752c36db9..09bb43b4d5 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -135,7 +135,7 @@ 'ocs' => [ ['name' => 'board_ocs#index', 'url' => '/api/v{apiVersion}/boards', 'verb' => 'GET'], ['name' => 'board_ocs#read', 'url' => '/api/v{apiVersion}/board/{boardId}', 'verb' => 'GET'], - ['name' => 'board_ocs#stacks', 'url' => '/api/v{apiVersion}/stacks/{boardId}', 'verb' => 'GET'], + ['name' => 'stack_ocs#index', 'url' => '/api/v{apiVersion}/stacks/{boardId}', 'verb' => 'GET'], ['name' => 'board_ocs#create', 'url' => '/api/v{apiVersion}/boards', 'verb' => 'POST'], ['name' => 'board_ocs#addAcl', 'url' => '/api/v{apiVersion}/boards/{boardId}/acl', 'verb' => 'POST'], @@ -145,6 +145,7 @@ ['name' => 'card_ocs#removeLabel', 'url' => '/api/v{apiVersion}/cards/{cardId}/label/{labelId}', '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'], ['name' => 'stack_ocs#delete', 'url' => '/api/v{apiVersion}/stacks/{stackId}/{boardId}', 'verb' => 'DELETE', 'defaults' => ['boardId' => null]], ['name' => 'Config#get', 'url' => '/api/v{apiVersion}/config', 'verb' => 'GET'], diff --git a/lib/Capabilities.php b/lib/Capabilities.php index eb8fcbca29..f33028a9cb 100644 --- a/lib/Capabilities.php +++ b/lib/Capabilities.php @@ -12,11 +12,8 @@ use OCP\Capabilities\ICapability; class Capabilities implements ICapability { - - /** @var IAppManager */ - private $appManager; - /** @var PermissionService */ - private $permissionService; + private IAppManager $appManager; + private PermissionService $permissionService; public function __construct(IAppManager $appManager, PermissionService $permissionService) { @@ -27,7 +24,7 @@ public function __construct(IAppManager $appManager, PermissionService $permissi /** * Function an app uses to return the capabilities * - * @return array{deck: array{version: string, canCreateBoards: bool, apiVersions: array}} + * @return array{deck: array{version: string, canCreateBoards: bool, supportsDoneColumn:true, apiVersions: array}} * @since 8.2.0 */ public function getCapabilities() { diff --git a/lib/Controller/BoardOcsController.php b/lib/Controller/BoardOcsController.php index 5786939039..3e9d4d254f 100644 --- a/lib/Controller/BoardOcsController.php +++ b/lib/Controller/BoardOcsController.php @@ -9,7 +9,6 @@ use OCA\Deck\Service\BoardService; use OCA\Deck\Service\ExternalBoardService; -use OCA\Deck\Service\StackService; use OCP\AppFramework\Http\Attribute\NoAdminRequired; use OCP\AppFramework\Http\Attribute\NoCSRFRequired; use OCP\AppFramework\Http\Attribute\PublicPage; @@ -26,7 +25,6 @@ public function __construct( private BoardService $boardService, private ExternalBoardService $externalBoardService, private LoggerInterface $logger, - private StackService $stackService, private $userId, ) { parent::__construct($appName, $request); @@ -58,20 +56,6 @@ public function create(string $title, string $color): DataResponse { return new DataResponse($this->boardService->create($title, $this->userId, $color)); } - #[NoAdminRequired] - #[PublicPage] - #[NoCSRFRequired] - #[RequestHeader(name: 'x-nextcloud-federation', description: 'Set to 1 when the request is performed by another Nextcloud Server to indicate a federation request', indirect: true)] - public function stacks(int $boardId): DataResponse { - $localBoard = $this->boardService->find($boardId, true, true); - // Board on other instance -> get it from other instance - if ($localBoard->getExternalId() !== null) { - return $this->externalBoardService->getExternalStacksFromRemote($localBoard); - } else { - return new DataResponse($this->stackService->findAll($boardId)); - } - } - #[NoAdminRequired] #[NoCSRFRequired] public function addAcl(int $boardId, int $type, string $participant, bool $permissionEdit, bool $permissionShare, bool $permissionManage, ?string $remote = null): DataResponse { diff --git a/lib/Controller/StackController.php b/lib/Controller/StackController.php index 712eacffd6..330cc4abaa 100644 --- a/lib/Controller/StackController.php +++ b/lib/Controller/StackController.php @@ -70,4 +70,5 @@ public function delete(int $stackId): Stack { public function deleted(int $boardId): array { return $this->stackService->fetchDeleted($boardId); } + } diff --git a/lib/Controller/StackOcsController.php b/lib/Controller/StackOcsController.php index a5fd656b9e..615a6d9ad2 100644 --- a/lib/Controller/StackOcsController.php +++ b/lib/Controller/StackOcsController.php @@ -29,6 +29,19 @@ public function __construct( parent::__construct($appName, $request); } + #[NoAdminRequired] + #[PublicPage] + #[NoCSRFRequired] + #[RequestHeader(name: 'x-nextcloud-federation', description: 'Set to 1 when the request is performed by another Nextcloud Server to indicate a federation request', indirect: true)] + public function index(int $boardId): DataResponse { + $localBoard = $this->boardService->find($boardId, true, true); + if ($localBoard->getExternalId() !== null) { + return $this->externalBoardService->getExternalStacksFromRemote($localBoard); + } else { + return new DataResponse($this->stackService->findAll($boardId)); + } + } + #[NoAdminRequired] #[PublicPage] #[NoCSRFRequired] @@ -44,6 +57,20 @@ public function create(string $title, int $boardId, int $order = 0):DataResponse }; } + #[NoAdminRequired] + #[PublicPage] + #[NoCSRFRequired] + #[RequestHeader(name: 'x-nextcloud-federation', description: 'Set to 1 when the request is performed by another Nextcloud Server to indicate a federation request', indirect: true)] + public function setDoneStack(int $stackId, int $boardId, bool $isDone): DataResponse { + $board = $this->boardService->find($boardId, false); + if ($board->getExternalId()) { + $result = $this->externalBoardService->setDoneStackOnRemote($board, $stackId, $isDone); + return new DataResponse($result); + } + $this->stackService->setDoneStack($stackId, $boardId, $isDone); + return new DataResponse(); + } + #[NoAdminRequired] #[PublicPage] #[NoCSRFRequired] diff --git a/lib/Db/Stack.php b/lib/Db/Stack.php index 482e72d34b..7e92911fd4 100644 --- a/lib/Db/Stack.php +++ b/lib/Db/Stack.php @@ -22,6 +22,8 @@ * @method \int getOrder() * @method void setOrder(int $order) * @method Card[] getCards() + * @method bool getIsDoneColumn() + * @method void setIsDoneColumn(bool $isDoneColumn) */ class Stack extends RelationalEntity { protected $title; @@ -30,6 +32,7 @@ class Stack extends RelationalEntity { protected $lastModified = 0; protected $cards = []; protected $order; + protected $isDoneColumn = false; public function __construct() { $this->addType('id', 'integer'); @@ -37,6 +40,7 @@ public function __construct() { $this->addType('deletedAt', 'integer'); $this->addType('lastModified', 'integer'); $this->addType('order', 'integer'); + $this->addType('isDoneColumn', 'boolean'); } public function setCards($cards) { diff --git a/lib/Db/StackMapper.php b/lib/Db/StackMapper.php index ad27680d1a..1ce0d5fa72 100644 --- a/lib/Db/StackMapper.php +++ b/lib/Db/StackMapper.php @@ -108,6 +108,36 @@ public function update(Entity $entity): Entity { return $result; } + public function clearDoneColumnForBoard(int $boardId): void { + $qb = $this->db->getQueryBuilder(); + $qb->update($this->getTableName()) + ->set('is_done_column', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL)) + ->where($qb->expr()->eq('board_id', $qb->createNamedParameter($boardId, IQueryBuilder::PARAM_INT))) + ->executeStatement(); + } + + public function setIsDoneColumn(int $stackId, bool $isDone): void { + $qb = $this->db->getQueryBuilder(); + $qb->update($this->getTableName()) + ->set('is_done_column', $qb->createNamedParameter($isDone, IQueryBuilder::PARAM_BOOL)) + ->where($qb->expr()->eq('id', $qb->createNamedParameter($stackId, IQueryBuilder::PARAM_INT))) + ->executeStatement(); + } + + public function findDoneColumnForBoard(int $boardId): ?Stack { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('board_id', $qb->createNamedParameter($boardId, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('is_done_column', $qb->createNamedParameter(true, IQueryBuilder::PARAM_BOOL))) + ->andWhere($qb->expr()->eq('deleted_at', $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT))); + try { + return $this->findEntity($qb); + } catch (DoesNotExistException|MultipleObjectsReturnedException $e) { + return null; + } + } + public function delete(Entity $entity): Entity { // delete cards on stack $this->cardMapper->deleteByStack($entity->getId()); diff --git a/lib/Migration/Version11002Date20260228000000.php b/lib/Migration/Version11002Date20260228000000.php new file mode 100644 index 0000000000..d7662254a8 --- /dev/null +++ b/lib/Migration/Version11002Date20260228000000.php @@ -0,0 +1,30 @@ +hasTable('deck_stacks')) { + $table = $schema->getTable('deck_stacks'); + if (!$table->hasColumn('is_done_column')) { + $table->addColumn('is_done_column', 'boolean', [ + 'notnull' => false, + 'default' => false, + ]); + } + } + return $schema; + } +} diff --git a/lib/Service/CardService.php b/lib/Service/CardService.php index 3b9f97d42b..90182dd2b3 100644 --- a/lib/Service/CardService.php +++ b/lib/Service/CardService.php @@ -430,7 +430,21 @@ public function reorder(int $id, int $stackId, int $order): array { throw new StatusException('Operation not allowed. This card is archived.'); } $changes = new ChangeSet($card); + $oldStackId = $card->getStackId(); $card->setStackId($stackId); + + if ($stackId !== $oldStackId) { + $newStack = $this->stackMapper->find($stackId); + if ($newStack->getIsDoneColumn()) { + $card->setDone(new \DateTime()); + } else { + $oldStack = $this->stackMapper->find($oldStackId); + if ($oldStack->getIsDoneColumn()) { + $card->setDone(null); + } + } + } + $this->cardMapper->update($card); $changes->setAfter($card); $this->activityManager->triggerUpdateEvents(ActivityManager::DECK_OBJECT_CARD, $changes, ActivityManager::SUBJECT_CARD_UPDATE); @@ -533,6 +547,15 @@ public function done(int $id): Card { $changes = new ChangeSet($card); $card->setDone(new \DateTime()); $newCard = $this->cardMapper->update($card); + // Auto-move to done column if one is configured and card is not already there + $currentStack = $this->stackMapper->find($newCard->getStackId()); + if (!$currentStack->getIsDoneColumn()) { + $doneStack = $this->stackMapper->findDoneColumnForBoard($currentStack->getBoardId()); + if ($doneStack !== null) { + $newCard->setStackId($doneStack->getId()); + $newCard = $this->cardMapper->update($newCard); + } + } $this->notificationHelper->markDuedateAsRead($card); $this->activityManager->triggerEvent(ActivityManager::DECK_OBJECT_CARD, $newCard, ActivityManager::SUBJECT_CARD_UPDATE_DONE); $this->changeHelper->cardChanged($id, false); diff --git a/lib/Service/ExternalBoardService.php b/lib/Service/ExternalBoardService.php index 6125c63b30..6744253bd3 100644 --- a/lib/Service/ExternalBoardService.php +++ b/lib/Service/ExternalBoardService.php @@ -180,6 +180,39 @@ public function createStackOnRemote( return $this->localizeRemoteStacks([$stack], $localBoard)[0]; } + public function getRemoteCapabilities(Board $localBoard): array { + $ownerCloudId = $this->cloudIdManager->resolveCloudId($localBoard->getOwner()); + $url = $ownerCloudId->getRemote() . '/ocs/v2.php/cloud/capabilities'; + $resp = $this->proxy->get('', '', $url); + $data = $this->proxy->getOCSData($resp); + return $data['capabilities']['deck'] ?? []; + } + + public function remoteSupportsCapability(Board $localBoard, string $capability): bool { + $capabilities = $this->getRemoteCapabilities($localBoard); + return !empty($capabilities[$capability]); + } + + public function setDoneStackOnRemote(Board $localBoard, int $stackId, bool $isDone): array { + $this->configService->ensureFederationEnabled(); + $this->permissionService->checkPermission($this->boardMapper, $localBoard->getId(), Acl::PERMISSION_MANAGE, $this->userId, false, false); + + if (!$this->remoteSupportsCapability($localBoard, 'supportsDoneColumn')) { + throw new \Exception('Remote server does not support the done column feature'); + } + + $shareToken = $localBoard->getShareToken(); + $participantCloudId = $this->cloudIdManager->getCloudId($this->userId, null); + $ownerCloudId = $this->cloudIdManager->resolveCloudId($localBoard->getOwner()); + $url = $ownerCloudId->getRemote() . '/ocs/v2.php/apps/deck/api/v1.0/stacks/' . $stackId . '/done'; + $params = [ + 'boardId' => $localBoard->getExternalId(), + 'isDone' => $isDone, + ]; + $resp = $this->proxy->put($participantCloudId->getId(), $shareToken, $url, $params); + return $this->proxy->getOcsData($resp); + } + public function deleteStackOnRemote(Board $localBoard, int $stackId): array { $this->configService->ensureFederationEnabled(); $this->permissionService->checkPermission($this->boardMapper, $localBoard->getId(), Acl::PERMISSION_EDIT, $this->userId, false, false); diff --git a/lib/Service/StackService.php b/lib/Service/StackService.php index c2aed66e57..67202f7dbd 100644 --- a/lib/Service/StackService.php +++ b/lib/Service/StackService.php @@ -304,4 +304,37 @@ public function reorder(int $id, int $order): array { return $result; } + + /** + * Set or unset a stack as the "done column" for the board + * + * @throws StatusException + * @throws \OCA\Deck\NoPermissionException + * @throws \OCP\AppFramework\Db\DoesNotExistException + * @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException + * @throws BadRequestException + */ + public function setDoneStack(int $stackId, int $boardId, bool $isDone): void { + $this->permissionService->checkPermission($this->stackMapper, $stackId, Acl::PERMISSION_MANAGE); + + if ($this->boardService->isArchived($this->stackMapper, $stackId)) { + throw new NoPermissionException('Operation not allowed. This board is archived.'); + } + + if ($isDone) { + $this->stackMapper->clearDoneColumnForBoard($boardId); + // Mark all existing cards in the stack as done + /** @var Card $card */ + foreach ($this->cardMapper->findAll($stackId) as $card) { + if ($card->getDone() === null) { + $card->setDone(new \DateTime()); + $this->cardMapper->update($card); + } + } + } + + $this->stackMapper->setIsDoneColumn($stackId, $isDone); + $this->changeHelper->boardChanged($boardId); + $this->eventDispatcher->dispatchTyped(new BoardUpdatedEvent($boardId)); + } } diff --git a/src/components/board/Stack.vue b/src/components/board/Stack.vue index 7300a40b05..3a657a17ba 100644 --- a/src/components/board/Stack.vue +++ b/src/components/board/Stack.vue @@ -4,24 +4,29 @@ -->