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
1 change: 1 addition & 0 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -1126,6 +1126,7 @@ Deck stores user and app configuration values globally and per board. The GET en

| Config key | Description |
| --- | --- |
| boardOrder | Per-user ordered list of board ids used to persist the manual drag and drop order of boards in the web navigation (array of integers) |
| calendar | Determines if the calendar/tasks integration through the CalDAV backend is enabled for the user (boolean) |
| cardDetailsInModal | Determines if the bigger view is used (boolean) |
| cardIdBadge | Determines if the ID badges are displayed on cards (boolean) |
Expand Down
27 changes: 26 additions & 1 deletion lib/Service/ConfigService.php
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@ public function getAll(): array {
$data = [
'calendar' => $this->isCalendarEnabled(),
'cardDetailsInModal' => $this->isCardDetailsInModal(),
'cardIdBadge' => $this->isCardIdBadgeEnabled()
'cardIdBadge' => $this->isCardIdBadgeEnabled(),
'boardOrder' => $this->getBoardOrder()
];
if ($this->groupManager->isAdmin($userId)) {
$data['groupLimit'] = $this->get('groupLimit');
Expand Down Expand Up @@ -90,6 +91,8 @@ public function get(string $key) {
return false;
}
return (bool)$this->config->getUserValue($this->getUserId(), Application::APP_ID, 'cardIdBadge', false);
case 'boardOrder':
return $this->getBoardOrder();
}
return false;
}
Expand Down Expand Up @@ -181,6 +184,14 @@ public function set($key, $value) {
$this->config->setUserValue($userId, Application::APP_ID, 'cardIdBadge', (string)$value);
$result = $value;
break;
case 'boardOrder':
if (!is_array($value)) {
throw new BadRequestException('boardOrder must be a list of board ids');
}
$ids = array_values(array_map('intval', array_filter($value, 'is_numeric')));
$this->config->setUserValue($userId, Application::APP_ID, 'boardOrder', json_encode($ids));
$result = $ids;
break;
case 'board':
// extra check that user only send one of the allowed board settings and not something random
$parts = explode(':', $key, 3);
Expand Down Expand Up @@ -236,6 +247,20 @@ private function getGroupLimit() {
return array_filter($groups);
}

/** @return int[] */
private function getBoardOrder(): array {
$userId = $this->getUserId();
if ($userId === null) {
return [];
}
$value = $this->config->getUserValue($userId, Application::APP_ID, 'boardOrder', '[]');
$decoded = json_decode($value, true);
if (!is_array($decoded)) {
return [];
}
return array_values(array_map('intval', array_filter($decoded, 'is_numeric')));
}

public function getAttachmentFolder(?string $userId = null): string {
if ($userId === null && $this->getUserId() === null) {
throw new NoPermissionException('Must be logged in get the attachment folder');
Expand Down
1 change: 1 addition & 0 deletions src/components/navigation/AppNavigation.vue
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
:boards="noneArchivedBoards"
:open-on-add-boards="true"
:default-open="true"
:sortable="true"
icon="icon-deck">
<template #icon>
<DeckIcon :size="16" />
Expand Down
48 changes: 46 additions & 2 deletions src/components/navigation/AppNavigationBoardCategory.vue
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,18 @@
:exact="true"
:allow-collapse="collapsible"
:open="opened">
<AppNavigationBoard v-for="board in boardsSorted" :key="board.id" :board="board" />
<Container v-if="sortable"
lock-axis="y"
tag="div"
non-drag-area-selector="input, .app-navigation-entry__actions"
@drop="onDropBoard">
<Draggable v-for="board in boardsSorted" :key="board.id">
<AppNavigationBoard :board="board" />
</Draggable>
</Container>
<template v-else>
<AppNavigationBoard v-for="board in boardsSorted" :key="board.id" :board="board" />
</template>
<template #icon>
<slot name="icon" />
</template>
Expand All @@ -19,12 +30,17 @@
<script>
import AppNavigationBoard from './AppNavigationBoard.vue'
import { NcAppNavigationItem } from '@nextcloud/vue'
import { Container, Draggable } from 'vue-smooth-dnd'
import { showError } from '@nextcloud/dialogs'
import { sortBoards } from '../../helpers/boardSort.js'

export default {
name: 'AppNavigationBoardCategory',
components: {
NcAppNavigationItem,
AppNavigationBoard,
Container,
Draggable,
},
props: {
to: {
Expand Down Expand Up @@ -55,6 +71,10 @@ export default {
type: Boolean,
default: false,
},
sortable: {
type: Boolean,
default: false,
},
},
data() {
return {
Expand All @@ -63,7 +83,7 @@ export default {
},
computed: {
boardsSorted() {
return [...this.boards].sort((a, b) => a.title.localeCompare(b.title))
return sortBoards(this.boards, this.sortable ? this.$store.getters.config('boardOrder') : null)
},
collapsible() {
return this.boards.length > 0
Expand All @@ -79,5 +99,29 @@ export default {
mounted() {
this.opened = this.defaultOpen
},
methods: {
async onDropBoard({ removedIndex, addedIndex }) {
if (removedIndex === null || addedIndex === null || removedIndex === addedIndex) {
return
}
const ordered = [...this.boardsSorted]
const [moved] = ordered.splice(removedIndex, 1)
ordered.splice(addedIndex, 0, moved)
try {
await this.$store.dispatch('setConfig', { boardOrder: ordered.map((board) => board.id) })
} catch (error) {
console.error('Failed to reorder boards', error)
showError(t('deck', 'Failed to reorder boards'))
}
},
},
}
</script>

<style lang="scss" scoped>
.smooth-dnd-container {
display: flex;
flex-direction: column;
gap: var(--default-grid-baseline, 4px);
}
</style>
24 changes: 24 additions & 0 deletions src/helpers/boardSort.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

/**
* Sort boards by the user defined order (list of board ids), boards not in
* the list come last, alphabetically.
*
* @param {Array} boards array of board objects ({ id, title })
* @param {Array|null} order ordered list of board ids or null
* @return {Array} new sorted array
*/
export function sortBoards(boards, order) {
const position = new Map((order ?? []).map((id, index) => [id, index]))
return [...boards].sort((a, b) => {
const positionA = position.has(a.id) ? position.get(a.id) : Number.POSITIVE_INFINITY
const positionB = position.has(b.id) ? position.get(b.id) : Number.POSITIVE_INFINITY
if (positionA !== positionB) {
return positionA - positionB
}
return a.title.localeCompare(b.title)
})
}
27 changes: 27 additions & 0 deletions src/helpers/boardSort.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { sortBoards } from './boardSort.js'

describe('sortBoards', () => {
it('sorts alphabetically without a custom order', () => {
const boards = [{ id: 1, title: 'Zebra' }, { id: 2, title: 'Alpha' }]
expect(sortBoards(boards, null).map(b => b.id)).toEqual([2, 1])
})

it('applies the custom order first', () => {
const boards = [{ id: 1, title: 'Alpha' }, { id: 2, title: 'Beta' }, { id: 3, title: 'Gamma' }]
expect(sortBoards(boards, [3, 1]).map(b => b.id)).toEqual([3, 1, 2])
})

it('appends unknown boards alphabetically after ordered ones', () => {
const boards = [{ id: 5, title: 'Zzz' }, { id: 6, title: 'Aaa' }, { id: 7, title: 'Mmm' }]
expect(sortBoards(boards, [7]).map(b => b.id)).toEqual([7, 6, 5])
})

it('ignores ids of removed boards', () => {
const boards = [{ id: 1, title: 'B' }, { id: 2, title: 'A' }]
expect(sortBoards(boards, [999, 2]).map(b => b.id)).toEqual([2, 1])
})
})
75 changes: 75 additions & 0 deletions tests/unit/Service/ConfigServiceTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<?php

declare(strict_types=1);

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

namespace OCA\Deck\Service;

use OCA\Deck\BadRequestException;
use OCP\IConfig;
use OCP\IGroupManager;
use PHPUnit\Framework\MockObject\MockObject;
use Test\TestCase;

class ConfigServiceTest extends TestCase {

private IConfig&MockObject $config;
private IGroupManager&MockObject $groupManager;
/** @var ConfigService|MockObject */
private $configService;

public function setUp(): void {
parent::setUp();
$this->config = $this->createMock(IConfig::class);
$this->groupManager = $this->createMock(IGroupManager::class);

// ConfigService::getUserId() resolves the current user through
// \OCP\Server::get(IUserSession::class) instead of a constructor
// dependency, so it can't be mocked via the constructor. Stub just
// that method and keep everything else on the real implementation.
$this->configService = $this->getMockBuilder(ConfigService::class)
->setConstructorArgs([$this->config, $this->groupManager])
->onlyMethods(['getUserId'])
->getMock();
$this->configService->method('getUserId')->willReturn('admin');
}

public function testBoardOrderRoundTrip() {
$this->config->expects($this->once())
->method('setUserValue')
->with('admin', 'deck', 'boardOrder', '[4,2,9]');
$result = $this->configService->set('boardOrder', [4, '2', 9.0]);
$this->assertSame([4, 2, 9], $result);
}

public function testBoardOrderRejectsNonArray() {
$this->expectException(BadRequestException::class);
$this->configService->set('boardOrder', 'not-a-list');
}

public function testGetBoardOrderDecodesAndFiltersStoredValue() {
$this->config->expects($this->once())
->method('getUserValue')
->with('admin', 'deck', 'boardOrder', '[]')
->willReturn('[3,1,5,"x"]');
$this->assertSame([3, 1, 5], $this->configService->get('boardOrder'));
}

public function testGetAllIncludesBoardOrder() {
$this->config->method('getUserValue')->willReturnCallback(function ($userId, $appId, $key, $default) {
if ($key === 'boardOrder') {
return '[7,3]';
}
return $default;
});
$this->config->method('getAppValue')->willReturnArgument(2);
$this->groupManager->method('isAdmin')->willReturn(false);

$data = $this->configService->getAll();
$this->assertSame([7, 3], $data['boardOrder']);
}
}