From f83b15d048ebb23c3064a0754ca29e0077544763 Mon Sep 17 00:00:00 2001 From: HUGO LOUREIRO <149155946+Lightshadow02@users.noreply.github.com> Date: Thu, 9 Jul 2026 23:01:47 +0200 Subject: [PATCH 1/5] feat(labels): add nullable order column and entity field Signed-off-by: HUGO LOUREIRO <149155946+Lightshadow02@users.noreply.github.com> Co-Authored-By: Claude Fable 5 --- lib/Db/Label.php | 4 +++ .../Version11003Date20260709000000.php | 30 +++++++++++++++++++ tests/unit/Db/LabelTest.php | 13 ++++++++ 3 files changed, 47 insertions(+) create mode 100644 lib/Migration/Version11003Date20260709000000.php diff --git a/lib/Db/Label.php b/lib/Db/Label.php index 02d68d7294..b48c722b48 100644 --- a/lib/Db/Label.php +++ b/lib/Db/Label.php @@ -18,6 +18,8 @@ * @method void setCardId(int $cardId) * @method int getLastModified() * @method void setLastModified(int $lastModified) + * @method int|null getOrder() + * @method void setOrder(?int $order) */ class Label extends RelationalEntity { protected $title; @@ -25,12 +27,14 @@ class Label extends RelationalEntity { protected $boardId; protected $cardId; protected $lastModified; + protected $order; public function __construct() { $this->addType('id', 'integer'); $this->addType('boardId', 'integer'); $this->addType('cardId', 'integer'); $this->addType('lastModified', 'integer'); + $this->addType('order', 'integer'); } public function getETag(): string { diff --git a/lib/Migration/Version11003Date20260709000000.php b/lib/Migration/Version11003Date20260709000000.php new file mode 100644 index 0000000000..13c2b011fa --- /dev/null +++ b/lib/Migration/Version11003Date20260709000000.php @@ -0,0 +1,30 @@ +hasTable('deck_labels')) { + $table = $schema->getTable('deck_labels'); + if (!$table->hasColumn('order')) { + $table->addColumn('order', 'integer', [ + 'notnull' => false, + 'default' => null, + ]); + } + } + return $schema; + } +} diff --git a/tests/unit/Db/LabelTest.php b/tests/unit/Db/LabelTest.php index 543be44680..2dea67ed51 100644 --- a/tests/unit/Db/LabelTest.php +++ b/tests/unit/Db/LabelTest.php @@ -45,6 +45,7 @@ public function testJsonSerializeBoard() { 'lastModified' => null, 'color' => '000000', 'ETag' => $label->getETag(), + 'order' => null, ], $label->jsonSerialize()); } public function testJsonSerializeCard() { @@ -58,6 +59,18 @@ public function testJsonSerializeCard() { 'lastModified' => null, 'color' => '000000', 'ETag' => $label->getETag(), + 'order' => null, ], $label->jsonSerialize()); } + public function testJsonSerializeContainsOrder() { + $label = new Label(); + $label->setId(1); + $label->setTitle('Priority'); + $label->setColor('ff0000'); + $label->setBoardId(2); + $this->assertArrayHasKey('order', $label->jsonSerialize()); + $this->assertNull($label->jsonSerialize()['order']); + $label->setOrder(3); + $this->assertSame(3, $label->jsonSerialize()['order']); + } } From e46642a805c62cbef0746625c7038fb9b31a49e4 Mon Sep 17 00:00:00 2001 From: HUGO LOUREIRO <149155946+Lightshadow02@users.noreply.github.com> Date: Thu, 9 Jul 2026 23:40:13 +0200 Subject: [PATCH 2/5] feat(labels): manual reorder endpoint and ordered label listings Signed-off-by: HUGO LOUREIRO <149155946+Lightshadow02@users.noreply.github.com> Co-Authored-By: Claude Fable 5 --- appinfo/routes.php | 4 ++ lib/Controller/LabelApiController.php | 11 +++++ lib/Controller/LabelController.php | 5 +++ lib/Db/LabelMapper.php | 15 ++++++- lib/Service/BoardService.php | 1 + lib/Service/LabelService.php | 53 +++++++++++++++++++++++++ tests/unit/Service/LabelServiceTest.php | 52 ++++++++++++++++++++++++ 7 files changed, 140 insertions(+), 1 deletion(-) diff --git a/appinfo/routes.php b/appinfo/routes.php index 146ec8d648..24b09e99e4 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -75,6 +75,7 @@ ['name' => 'label#create', 'url' => '/labels', 'verb' => 'POST'], ['name' => 'label#update', 'url' => '/labels/{labelId}', 'verb' => 'PUT'], ['name' => 'label#delete', 'url' => '/labels/{labelId}', 'verb' => 'DELETE'], + ['name' => 'label#reorder', 'url' => '/boards/{boardId}/labels/reorder', 'verb' => 'PUT'], // api ['name' => 'board_api#index', 'url' => '/api/v{apiVersion}/boards', 'verb' => 'GET'], @@ -116,6 +117,9 @@ ['name' => 'label_api#get', 'url' => '/api/v{apiVersion}/boards/{boardId}/labels/{labelId}', 'verb' => 'GET'], ['name' => 'label_api#create', 'url' => '/api/v{apiVersion}/boards/{boardId}/labels', 'verb' => 'POST'], + // must be registered before label_api#update: both are PUT and {labelId} would otherwise + // greedily match the literal "reorder" segment, since Symfony routing resolves in declaration order + ['name' => 'label_api#reorder', 'url' => '/api/v{apiVersion}/boards/{boardId}/labels/reorder', 'verb' => 'PUT'], ['name' => 'label_api#update', 'url' => '/api/v{apiVersion}/boards/{boardId}/labels/{labelId}', 'verb' => 'PUT'], ['name' => 'label_api#delete', 'url' => '/api/v{apiVersion}/boards/{boardId}/labels/{labelId}', 'verb' => 'DELETE'], diff --git a/lib/Controller/LabelApiController.php b/lib/Controller/LabelApiController.php index 5e9b2d665f..599cbd8850 100644 --- a/lib/Controller/LabelApiController.php +++ b/lib/Controller/LabelApiController.php @@ -76,4 +76,15 @@ public function delete(): DataResponse { $label = $this->labelService->delete($this->request->getParam('labelId')); return new DataResponse($label, HTTP::STATUS_OK); } + + /** + * Reorder the labels of a board + */ + #[NoAdminRequired] + #[NoCSRFRequired] + #[CORS] + public function reorder(int $boardId, array $labelIds): DataResponse { + $labels = $this->labelService->reorder($boardId, $labelIds); + return new DataResponse($labels, HTTP::STATUS_OK); + } } diff --git a/lib/Controller/LabelController.php b/lib/Controller/LabelController.php index c5c368caa4..ef6e4722bf 100644 --- a/lib/Controller/LabelController.php +++ b/lib/Controller/LabelController.php @@ -36,4 +36,9 @@ public function update(int $id, string $title, string $color): Label { public function delete(int $labelId): Label { return $this->labelService->delete($labelId); } + + #[NoAdminRequired] + public function reorder(int $boardId, array $labelIds): array { + return $this->labelService->reorder($boardId, $labelIds); + } } diff --git a/lib/Db/LabelMapper.php b/lib/Db/LabelMapper.php index 8ec9cbe690..30d5741bee 100644 --- a/lib/Db/LabelMapper.php +++ b/lib/Db/LabelMapper.php @@ -30,7 +30,20 @@ public function findAll(int $boardId, ?int $limit = null, int $offset = 0): arra ->where($qb->expr()->eq('board_id', $qb->createNamedParameter($boardId, IQueryBuilder::PARAM_INT))) ->setMaxResults($limit) ->setFirstResult($offset); - return $this->findEntities($qb); + $labels = $this->findEntities($qb); + // manual order first (see LabelService::reorder), NULLs last, then id for stability + usort($labels, static function (Label $a, Label $b): int { + $aOrder = $a->getOrder(); + $bOrder = $b->getOrder(); + if ($aOrder !== null && $bOrder !== null && $aOrder !== $bOrder) { + return $aOrder <=> $bOrder; + } + if (($aOrder === null) !== ($bOrder === null)) { + return $aOrder === null ? 1 : -1; + } + return $a->getId() <=> $b->getId(); + }); + return $labels; } /** diff --git a/lib/Service/BoardService.php b/lib/Service/BoardService.php index 1610352ae3..b736b5dc8b 100644 --- a/lib/Service/BoardService.php +++ b/lib/Service/BoardService.php @@ -594,6 +594,7 @@ public function clone( $newLabel->setTitle($label->getTitle()); $newLabel->setColor($label->getColor()); $newLabel->setBoardId($newBoard->getId()); + $newLabel->setOrder($label->getOrder()); $this->labelMapper->insert($newLabel); } diff --git a/lib/Service/LabelService.php b/lib/Service/LabelService.php index 625ef8a442..c5dc2aff60 100644 --- a/lib/Service/LabelService.php +++ b/lib/Service/LabelService.php @@ -60,6 +60,16 @@ public function create(string $title, string $color, int $boardId): Label { $label->setTitle($title); $label->setColor($color); $label->setBoardId($boardId); + $existingLabels = $this->labelMapper->findAll($boardId); + $maxOrder = null; + foreach ($existingLabels as $existingLabel) { + if ($existingLabel->getOrder() !== null) { + $maxOrder = $maxOrder === null ? $existingLabel->getOrder() : max($maxOrder, $existingLabel->getOrder()); + } + } + if ($maxOrder !== null) { + $label->setOrder($maxOrder + 1); + } $this->changeHelper->boardChanged($boardId); return $this->labelMapper->insert($label); @@ -125,4 +135,47 @@ public function update(int $id, string $title, string $color): Label { return $this->labelMapper->update($label); } + + /** + * Set the manual sort order of all labels of a board. + * + * @param int[] $labelIds every label id of the board, in the wanted order + * @return Label[] the board labels in their new order + * @throws BadRequestException + * @throws StatusException + * @throws \OCA\Deck\NoPermissionException + */ + public function reorder(int $boardId, array $labelIds): array { + $this->permissionService->checkPermission(null, $boardId, Acl::PERMISSION_MANAGE); + + if ($this->boardService->isArchived(null, $boardId)) { + throw new StatusException('Operation not allowed. This board is archived.'); + } + + $byId = []; + foreach ($this->labelMapper->findAll($boardId) as $label) { + $byId[$label->getId()] = $label; + } + + $labelIds = array_values(array_unique(array_map('intval', $labelIds))); + foreach ($labelIds as $labelId) { + if (!isset($byId[$labelId])) { + throw new BadRequestException('Label ' . $labelId . ' does not belong to board ' . $boardId); + } + } + if (count($labelIds) !== count($byId)) { + throw new BadRequestException('The ordered list must contain every label of the board exactly once'); + } + + foreach ($labelIds as $position => $labelId) { + $label = $byId[$labelId]; + if ($label->getOrder() !== $position) { + $label->setOrder($position); + $this->labelMapper->update($label); + } + } + $this->changeHelper->boardChanged($boardId); + + return $this->labelMapper->findAll($boardId); + } } diff --git a/tests/unit/Service/LabelServiceTest.php b/tests/unit/Service/LabelServiceTest.php index 038f4b8a8d..ed889cc04e 100644 --- a/tests/unit/Service/LabelServiceTest.php +++ b/tests/unit/Service/LabelServiceTest.php @@ -166,4 +166,56 @@ public function testDelete() { ->willReturn($label); $this->assertEquals($label, $this->labelService->delete(1)); } + + public function testReorder() { + $labelA = new Label(); + $labelA->setId(1); + $labelB = new Label(); + $labelB->setId(2); + $this->permissionService->expects($this->once())->method('checkPermission'); + $this->boardService->expects($this->once())->method('isArchived')->willReturn(false); + $this->labelMapper->expects($this->exactly(2))->method('findAll')->with(123) + ->willReturnOnConsecutiveCalls([$labelA, $labelB], [$labelB, $labelA]); + $this->labelMapper->expects($this->exactly(2))->method('update'); + $result = $this->labelService->reorder(123, [2, 1]); + // the ordered list [2, 1] puts label 2 at position 0 and label 1 at position 1 + $this->assertSame(0, $labelB->getOrder()); + $this->assertSame(1, $labelA->getOrder()); + $this->assertSame([$labelB, $labelA], $result); + } + + public function testReorderRejectsForeignLabel() { + $labelA = new Label(); + $labelA->setId(1); + $this->boardService->expects($this->once())->method('isArchived')->willReturn(false); + $this->labelMapper->expects($this->once())->method('findAll')->with(123)->willReturn([$labelA]); + $this->expectException(\OCA\Deck\BadRequestException::class); + $this->labelService->reorder(123, [99]); + } + + public function testReorderRejectsArchivedBoard() { + $this->boardService->expects($this->once())->method('isArchived')->willReturn(true); + $this->expectException(\OCA\Deck\StatusException::class); + $this->labelService->reorder(123, [1]); + } + + public function testReorderRejectsIncompleteList() { + $labelA = new Label(); + $labelA->setId(1); + $labelB = new Label(); + $labelB->setId(2); + $this->boardService->expects($this->once())->method('isArchived')->willReturn(false); + $this->labelMapper->expects($this->once())->method('findAll')->with(123)->willReturn([$labelA, $labelB]); + $this->expectException(\OCA\Deck\BadRequestException::class); + $this->labelService->reorder(123, [1]); + } + + public function testReorderRejectsEmptyList() { + $labelA = new Label(); + $labelA->setId(1); + $this->boardService->expects($this->once())->method('isArchived')->willReturn(false); + $this->labelMapper->expects($this->once())->method('findAll')->with(123)->willReturn([$labelA]); + $this->expectException(\OCA\Deck\BadRequestException::class); + $this->labelService->reorder(123, []); + } } From 9ccadeebf9cda9f91e78863fe47ffc60ca10ae04 Mon Sep 17 00:00:00 2001 From: HUGO LOUREIRO <149155946+Lightshadow02@users.noreply.github.com> Date: Thu, 9 Jul 2026 21:01:52 +0200 Subject: [PATCH 3/5] fix: point jest vue transform at installed @vue/vue2-jest package The jest config referenced vue-jest which is not a dependency; the installed transformer is @vue/vue2-jest. Went unnoticed as the repo had no jest tests yet. Co-Authored-By: Claude Fable 5 Signed-off-by: HUGO LOUREIRO <149155946+Lightshadow02@users.noreply.github.com> --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index bff842b330..e396c1cee4 100644 --- a/package.json +++ b/package.json @@ -105,7 +105,7 @@ }, "transform": { "^.+\\.js$": "/node_modules/babel-jest", - ".*\\.(vue)$": "/node_modules/vue-jest" + ".*\\.(vue)$": "/node_modules/@vue/vue2-jest" }, "snapshotSerializers": [ "/node_modules/jest-serializer-vue" From e1412e77d4eda99a987b89c9121359a812b983f0 Mon Sep 17 00:00:00 2001 From: HUGO LOUREIRO <149155946+Lightshadow02@users.noreply.github.com> Date: Fri, 10 Jul 2026 01:27:04 +0200 Subject: [PATCH 4/5] feat(labels): drag and drop manual ordering in the board sidebar Signed-off-by: HUGO LOUREIRO <149155946+Lightshadow02@users.noreply.github.com> Co-Authored-By: Claude Fable 5 --- src/components/board/TagsTabSidebar.vue | 122 ++++++++++++++---------- src/components/card/TagSelector.vue | 6 +- src/components/cards/CardItem.vue | 3 +- src/helpers/labelSort.js | 25 +++++ src/helpers/labelSort.spec.js | 38 ++++++++ src/services/BoardApi.js | 15 +++ src/store/main.js | 16 ++++ 7 files changed, 173 insertions(+), 52 deletions(-) create mode 100644 src/helpers/labelSort.js create mode 100644 src/helpers/labelSort.spec.js diff --git a/src/components/board/TagsTabSidebar.vue b/src/components/board/TagsTabSidebar.vue index 3f6c30b865..344b18422f 100644 --- a/src/components/board/TagsTabSidebar.vue +++ b/src/components/board/TagsTabSidebar.vue @@ -5,53 +5,60 @@