Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down Expand Up @@ -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'],

Expand Down
35 changes: 35 additions & 0 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions lib/Controller/LabelApiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
5 changes: 5 additions & 0 deletions lib/Controller/LabelController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
4 changes: 4 additions & 0 deletions lib/Db/Label.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,19 +18,23 @@
* @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;
protected $color;
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 {
Expand Down
16 changes: 15 additions & 1 deletion lib/Db/LabelMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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')
Expand Down
31 changes: 31 additions & 0 deletions lib/Migration/Version11003Date20260709000000.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

declare(strict_types=1);

namespace OCA\Deck\Migration;

use Closure;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;

class Version11003Date20260709000000 extends SimpleMigrationStep {
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options) {
$schema = $schemaClosure();

if ($schema->hasTable('deck_labels')) {
$table = $schema->getTable('deck_labels');
if (!$table->hasColumn('order')) {
$table->addColumn('order', 'integer', [
'notnull' => false,
'default' => null,
]);
}
}
return $schema;
}
}
1 change: 1 addition & 0 deletions lib/Service/BoardService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
53 changes: 53 additions & 0 deletions lib/Service/LabelService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
}
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@
},
"transform": {
"^.+\\.js$": "<rootDir>/node_modules/babel-jest",
".*\\.(vue)$": "<rootDir>/node_modules/vue-jest"
".*\\.(vue)$": "<rootDir>/node_modules/@vue/vue2-jest"
},
"snapshotSerializers": [
"<rootDir>/node_modules/jest-serializer-vue"
Expand Down
3 changes: 2 additions & 1 deletion src/components/Controls.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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 []
Expand Down
Loading