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/docs/API.md b/docs/API.md index c3d40d265d..022aaca11f 100644 --- a/docs/API.md +++ b/docs/API.md @@ -868,10 +868,13 @@ The request can fail with a bad request response for the following reasons: "color": "31CC7C", "boardId": "2", "cardId": null, + "order": null, "id": 5 } ``` +The `order` field (int, nullable) reflects the manual sorting position set through the reorder endpoint below. Labels with `order: null` have not been manually ordered yet and are sorted alphabetically after the ordered ones. + ### POST /boards/{boardId}/labels - Create a new label #### Request parameters @@ -929,6 +932,38 @@ The request can fail with a bad request response for the following reasons: ##### 200 Success +### PUT /boards/{boardId}/labels/reorder - Change the sorting order of the board's labels + +Requires `PERMISSION_MANAGE` on the board. + +#### Request parameters + +| Parameter | Type | Description | +| --------- | ------- | ---------------------------------------- | +| boardId | Integer | The id of the board the labels belong to | + +#### Request data + +| Parameter | Type | Description | +| --------- | --------- | ---------------------------------------------------------------------------| +| labelIds | Integer[] | The ids of every label of the board, listed exactly once, in the desired display order | + +```json +{ + "labelIds": [39, 37, 40, 38] +} +``` + +#### Response + +##### 200 Success + +Returns the board's labels in their new order (same format as the label list above). + +##### 400 Bad request + +A bad request response is returned if `labelIds` does not contain every label of the board exactly once, for example because a label id is missing, unknown/foreign to the board, or duplicated. + ## Attachments ### GET /boards/{boardId}/stacks/{stackId}/cards/{cardId}/attachments - Get a list of attachments 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/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/Db/LabelMapper.php b/lib/Db/LabelMapper.php index 8ec9cbe690..91ec224621 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; } /** @@ -101,6 +114,7 @@ public function findAssignedLabelsForCards(array $cardIds, ?int $limit = null, i public function findAssignedLabelsForBoard(int $boardId, ?int $limit = null, int $offset = 0): array { $qb = $this->db->getQueryBuilder(); $qb->select('l.id as id', 'l.title as title', 'l.color as color') + ->selectAlias('l.order', 'order') ->selectAlias('c.id', 'card_id') ->from($this->getTableName(), 'l') ->innerJoin('l', 'deck_assigned_labels', 'al', 'al.label_id = l.id') diff --git a/lib/Migration/Version11003Date20260709000000.php b/lib/Migration/Version11003Date20260709000000.php new file mode 100644 index 0000000000..30b0f94e5a --- /dev/null +++ b/lib/Migration/Version11003Date20260709000000.php @@ -0,0 +1,31 @@ +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/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/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" diff --git a/src/components/Controls.vue b/src/components/Controls.vue index c84b126a0e..ddcb903f7c 100644 --- a/src/components/Controls.vue +++ b/src/components/Controls.vue @@ -294,6 +294,7 @@ import SessionList from './SessionList.vue' import { isNotifyPushEnabled } from '../sessions.js' import CreateNewCardCustomPicker from '../views/CreateNewCardCustomPicker.vue' import { getCurrentUser } from '@nextcloud/auth' +import { sortLabels } from '../helpers/labelSort.js' export default { name: 'Controls', @@ -365,7 +366,7 @@ export default { return this.filter.tags.length !== 0 || this.filter.users.length !== 0 || this.filter.due !== '' || this.filter.completed !== 'both' }, labelsSorted() { - return [...this.board.labels].sort((a, b) => (a.title < b.title) ? -1 : 1) + return sortLabels(this.board.labels) }, presentUsers() { if (!this.board) return [] diff --git a/src/components/board/TagsTabSidebar.vue b/src/components/board/TagsTabSidebar.vue index 3f6c30b865..cbb1563eeb 100644 --- a/src/components/board/TagsTabSidebar.vue +++ b/src/components/board/TagsTabSidebar.vue @@ -5,53 +5,61 @@