diff --git a/cypress/e2e/swimlanesFeatures.js b/cypress/e2e/swimlanesFeatures.js
new file mode 100644
index 000000000..c3af2f393
--- /dev/null
+++ b/cypress/e2e/swimlanesFeatures.js
@@ -0,0 +1,159 @@
+/**
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import { randUser } from '../utils/index.js'
+
+const user = randUser()
+
+// Builds a board with three stacks, three labels, two cards \u2014 one card
+// has two labels so it should render in two lanes when grouped by labels.
+function seedSwimlaneBoard() {
+ const auth = { user: user.userId, password: user.password }
+ const baseUrl = Cypress.env('baseUrl')
+ const api = `${baseUrl}/index.php/apps/deck/api/v1.0`
+
+ return cy.request({
+ method: 'POST',
+ url: `${api}/boards`,
+ auth,
+ body: { title: 'Swimlanes', color: '00ff00' },
+ }).then(({ body: board }) => {
+ const boardId = board.id
+
+ const stackReq = (title) => cy.request({
+ method: 'POST',
+ url: `${api}/boards/${boardId}/stacks`,
+ auth,
+ body: { title, order: 0 },
+ })
+ const labelReq = (title, color) => cy.request({
+ method: 'POST',
+ url: `${api}/boards/${boardId}/labels`,
+ auth,
+ body: { title, color },
+ }).then(({ body }) => body)
+
+ return Cypress.Promise.all([
+ stackReq('Todo'), stackReq('Doing'), stackReq('Done'),
+ labelReq('Bug', 'ff0000'), labelReq('Feature', '00ff00'),
+ labelReq('Backend', '0000ff'),
+ ]).then((results) => {
+ const [todoStack, , , bug, feature, backend] = results.map((r) => r.body ?? r)
+ const todoId = todoStack.id
+ const mk = (title) => cy.request({
+ method: 'POST',
+ url: `${api}/boards/${boardId}/stacks/${todoId}/cards`,
+ auth,
+ body: { title, description: '' },
+ }).then(({ body }) => body)
+ return cy.wrap(null).then(() =>
+ mk('Fix login bug').then((card) =>
+ cy.request({ // two labels on this card
+ method: 'PUT',
+ url: `${api}/boards/${boardId}/stacks/${todoId}/cards/${card.id}/assignLabel`,
+ auth,
+ body: { labelId: bug.id },
+ }).then(() => cy.request({
+ method: 'PUT',
+ url: `${api}/boards/${boardId}/stacks/${todoId}/cards/${card.id}/assignLabel`,
+ auth,
+ body: { labelId: backend.id },
+ }))
+ ).then(() =>
+ mk('Ship feature').then((card) =>
+ cy.request({
+ method: 'PUT',
+ url: `${api}/boards/${boardId}/stacks/${todoId}/cards/${card.id}/assignLabel`,
+ auth,
+ body: { labelId: feature.id },
+ })
+ )
+ ).then(() => mk('Unlabeled work'))
+ .then(() => cy.wrap({ boardId }))
+ )
+ })
+ })
+}
+
+describe('Swimlane grouping', function() {
+ let boardId
+
+ before(function() {
+ cy.createUser(user)
+ cy.login(user)
+ seedSwimlaneBoard().then((ctx) => { boardId = ctx.boardId })
+ })
+
+ beforeEach(function() {
+ cy.login(user)
+ cy.visit(`/apps/deck/board/${boardId}`)
+ cy.get('.stack', { timeout: 10000 }).should('have.length', 3)
+ })
+
+ afterEach(function() {
+ // Reset the board to flat view between tests so each test is independent
+ cy.request({
+ method: 'POST',
+ url: `${Cypress.env('baseUrl')}/index.php/apps/deck/api/v1.0/config/board:${boardId}:swimlaneMode`,
+ auth: { user: user.userId, password: user.password },
+ body: { value: 'none' },
+ })
+ })
+
+ it('flat view shows no swimlanes initially', function() {
+ cy.get('.swimlane').should('not.exist')
+ cy.get('.card').should('have.length.at.least', 3)
+ })
+
+ it('groups cards by labels', function() {
+ cy.get('button[aria-label="View Modes"]').first().click()
+ cy.contains('Group by labels').click()
+
+ cy.get('.swimlane', { timeout: 8000 }).should('have.length.at.least', 4)
+ cy.get('.swimlane-header__title, .swimlane-header__label').should('contain.text', 'Bug')
+ cy.get('.swimlane-header__title, .swimlane-header__label').should('contain.text', 'Feature')
+ cy.get('.swimlane-header__title, .swimlane-header__label').should('contain.text', 'Backend')
+ cy.get('.swimlane-header__title, .swimlane-header__label').last().should('contain.text', 'No label')
+ })
+
+ it('renders a multi-label card in every matching lane', function() {
+ cy.get('button[aria-label="View Modes"]').first().click()
+ cy.contains('Group by labels').click()
+ cy.get('.swimlane', { timeout: 8000 }).should('have.length.at.least', 4)
+
+ // "Fix login bug" has Bug + Backend labels \u2014 must appear in both lanes
+ const titleXpath = (lane) => `.swimlane:has(.swimlane-header:contains("${lane}")) .card:contains("Fix login bug")`
+ cy.get(titleXpath('Bug')).should('exist')
+ cy.get(titleXpath('Backend')).should('exist')
+ })
+
+ it('groups cards by assignees with Unassigned catchall last', function() {
+ cy.get('button[aria-label="View Modes"]').first().click()
+ cy.contains('Group by assignees').click()
+
+ cy.get('.swimlane', { timeout: 8000 }).should('have.length.at.least', 1)
+ cy.get('.swimlane-header__title').last().should('contain.text', 'Unassigned')
+ })
+
+ it('returns to flat view when No grouping is selected', function() {
+ cy.get('button[aria-label="View Modes"]').first().click()
+ cy.contains('Group by labels').click()
+ cy.get('.swimlane', { timeout: 8000 }).should('exist')
+
+ cy.get('button[aria-label="View Modes"]').first().click()
+ cy.contains('No grouping').click()
+
+ cy.get('.swimlane').should('not.exist')
+ cy.get('.stack').should('have.length', 3)
+ })
+
+ it('persists the grouping mode across reload', function() {
+ cy.get('button[aria-label="View Modes"]').first().click()
+ cy.contains('Group by labels').click()
+ cy.get('.swimlane', { timeout: 8000 }).should('exist')
+
+ cy.reload()
+ cy.get('.swimlane', { timeout: 10000 }).should('have.length.at.least', 4)
+ })
+})
diff --git a/lib/Service/BoardService.php b/lib/Service/BoardService.php
index e08b34b8c..fbeb303e6 100644
--- a/lib/Service/BoardService.php
+++ b/lib/Service/BoardService.php
@@ -342,9 +342,13 @@ private function applyPermissions(int $boardId, bool $edit, bool $share, bool $m
public function enrichWithBoardSettings(Board $board): void {
$globalCalendarConfig = (bool)$this->config->getUserValue($this->userId, Application::APP_ID, 'calendar', true);
+ $boardId = $board->getId();
$settings = [
- 'notify-due' => $this->config->getUserValue($this->userId, Application::APP_ID, 'board:' . $board->getId() . ':notify-due', ConfigService::SETTING_BOARD_NOTIFICATION_DUE_ASSIGNED),
- 'calendar' => $this->config->getUserValue($this->userId, Application::APP_ID, 'board:' . $board->getId() . ':calendar', $globalCalendarConfig),
+ 'notify-due' => $this->config->getUserValue($this->userId, Application::APP_ID, 'board:' . $boardId . ':notify-due', ConfigService::SETTING_BOARD_NOTIFICATION_DUE_ASSIGNED),
+ 'calendar' => $this->config->getUserValue($this->userId, Application::APP_ID, 'board:' . $boardId . ':calendar', $globalCalendarConfig),
+ 'swimlaneMode' => $this->config->getAppValue(Application::APP_ID, 'board:' . $boardId . ':swimlaneMode', 'none'),
+ 'swimlaneLabelOrder' => $this->config->getAppValue(Application::APP_ID, 'board:' . $boardId . ':swimlaneLabelOrder', '[]'),
+ 'swimlaneUserOrder' => $this->config->getAppValue(Application::APP_ID, 'board:' . $boardId . ':swimlaneUserOrder', '[]'),
];
$board->setSettings($settings);
}
diff --git a/lib/Service/ConfigService.php b/lib/Service/ConfigService.php
index 92880ff39..0bce77cee 100644
--- a/lib/Service/ConfigService.php
+++ b/lib/Service/ConfigService.php
@@ -12,6 +12,8 @@
use OCA\Deck\AppInfo\Application;
use OCA\Deck\BadRequestException;
+use OCA\Deck\Db\Acl;
+use OCA\Deck\Db\BoardMapper;
use OCA\Deck\Exceptions\FederationDisabledException;
use OCA\Deck\NoPermissionException;
use OCP\IConfig;
@@ -25,11 +27,15 @@ class ConfigService {
public const SETTING_BOARD_NOTIFICATION_DUE_ALL = 'all';
public const SETTING_BOARD_NOTIFICATION_DUE_DEFAULT = self::SETTING_BOARD_NOTIFICATION_DUE_ASSIGNED;
+ private const SWIMLANE_CONFIG_KEYS = ['swimlaneMode', 'swimlaneLabelOrder', 'swimlaneUserOrder'];
+
private ?string $userId = null;
public function __construct(
private readonly IConfig $config,
private readonly IGroupManager $groupManager,
+ private readonly PermissionService $permissionService,
+ private readonly BoardMapper $boardMapper,
) {
}
@@ -183,11 +189,18 @@ public function set($key, $value) {
$result = $value;
break;
case 'board':
- [$boardId, $boardConfigKey] = explode(':', $key);
+ $parts = explode(':', $key, 3);
+ $boardId = $parts[1] ?? '';
+ $boardConfigKey = $parts[2] ?? '';
if ($boardConfigKey === 'notify-due' && !in_array($value, [self::SETTING_BOARD_NOTIFICATION_DUE_ALL, self::SETTING_BOARD_NOTIFICATION_DUE_ASSIGNED, self::SETTING_BOARD_NOTIFICATION_DUE_OFF], true)) {
throw new BadRequestException('Board notification option must be one of: off, assigned, all');
}
- $this->config->setUserValue($userId, Application::APP_ID, $key, (string)$value);
+ if (in_array($boardConfigKey, self::SWIMLANE_CONFIG_KEYS, true)) {
+ $this->permissionService->checkPermission($this->boardMapper, (int)$boardId, Acl::PERMISSION_EDIT);
+ $this->config->setAppValue(Application::APP_ID, $key, (string)$value);
+ } else {
+ $this->config->setUserValue($userId, Application::APP_ID, $key, (string)$value);
+ }
$result = $value;
}
return $result;
diff --git a/src/components/Controls.vue b/src/components/Controls.vue
index ef7ee270d..9a07fffbe 100644
--- a/src/components/Controls.vue
+++ b/src/components/Controls.vue
@@ -248,6 +248,26 @@
{{ showCardCover ? t('deck', 'Hide card cover images') : t('deck', 'Show card cover images') }}
+
+
+
+ {{ t('deck', 'No grouping') }}
+
+
+ {{ t('deck', 'Labels') }}
+
+
+ {{ t('deck', 'Assignees') }}
+
@@ -263,8 +283,8 @@
+
+
diff --git a/src/components/board/SwimlaneHeader.vue b/src/components/board/SwimlaneHeader.vue
new file mode 100644
index 000000000..5859f505a
--- /dev/null
+++ b/src/components/board/SwimlaneHeader.vue
@@ -0,0 +1,166 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/components/cards/CardBadges.vue b/src/components/cards/CardBadges.vue
index 1d88fe311..e29fbb671 100644
--- a/src/components/cards/CardBadges.vue
+++ b/src/components/cards/CardBadges.vue
@@ -33,6 +33,13 @@
{{ card.attachmentCount }}
+
+
@@ -51,6 +58,7 @@ import AttachmentIcon from 'vue-material-design-icons/Paperclip.vue'
import CheckmarkIcon from 'vue-material-design-icons/CheckboxMarked.vue'
import CommentIcon from 'vue-material-design-icons/CommentOutline.vue'
import CommentUnreadIcon from 'vue-material-design-icons/CommentAccountOutline.vue'
+import ArrowTopRightThinIcon from 'vue-material-design-icons/ArrowTopRightThin.vue'
import DueDate from './badges/DueDate.vue'
export default {
@@ -64,6 +72,7 @@ export default {
CommentIcon,
CommentUnreadIcon,
CardId,
+ ArrowTopRightThinIcon,
},
props: {
card: {
@@ -72,6 +81,23 @@ export default {
},
},
computed: {
+ laneCount() {
+ const board = this.$store.state.currentBoard
+ if (!board?.id) {
+ return 0
+ }
+ const mode = board?.settings?.swimlaneMode || 'none'
+ if (mode === 'none') {
+ return 0
+ }
+ if (mode === 'labels') {
+ return (this.card.labels && this.card.labels.length > 1) ? this.card.labels.length : 0
+ }
+ if (mode === 'assignees') {
+ return (this.card.assignedUsers && this.card.assignedUsers.length > 1) ? this.card.assignedUsers.length : 0
+ }
+ return 0
+ },
checkListCount() {
return (this.card.description.match(/^\s*([*+-]|(\d\.))\s+\[\s*(\s|x)\s*\](.*)$/gim) || []).length
},
@@ -120,6 +146,10 @@ export default {
&:deep(span) {
padding: 2px;
}
+
+ &--lanes {
+ opacity: 0.7;
+ }
}
}
diff --git a/src/store/card.js b/src/store/card.js
index 85986d8d0..ef3ad2749 100644
--- a/src/store/card.js
+++ b/src/store/card.js
@@ -184,6 +184,25 @@ export default function cardModuleFactory() {
})
.sort((a, b) => a.order - b.order || a.createdAt - b.createdAt)
},
+ cardsByStackAndLane: (state, getters) => (stackId, laneType, laneKey) => {
+ const cards = getters.cardsByStack(stackId)
+ if (!laneType || laneType === 'none') {
+ return cards
+ }
+ if (laneType === 'label') {
+ if (laneKey === '__none__') {
+ return cards.filter(c => !c.labels || c.labels.length === 0)
+ }
+ return cards.filter(c => c.labels && c.labels.some(l => l.id === laneKey))
+ }
+ if (laneType === 'assignee') {
+ if (laneKey === '__none__') {
+ return cards.filter(c => !c.assignedUsers || c.assignedUsers.length === 0)
+ }
+ return cards.filter(c => c.assignedUsers && c.assignedUsers.some(u => u.participant.uid === laneKey))
+ }
+ return cards
+ },
cardById: state => (id) => {
return state.cards.find((card) => card.id === id)
},
diff --git a/src/store/main.js b/src/store/main.js
index 8b7a55b6c..632e91fe4 100644
--- a/src/store/main.js
+++ b/src/store/main.js
@@ -61,6 +61,8 @@ export default function storeFactory() {
activity: [],
activityLoadMore: true,
filter: { tags: [], users: [], due: '', unassigned: false, completed: 'both' },
+ swimlaneLabelOrder: {},
+ swimlaneUserOrder: {},
shortcutLock: false,
},
getters: {
@@ -299,6 +301,15 @@ export default function storeFactory() {
Vue.delete(state.currentBoard.acl, removeIndex)
}
},
+ SET_SWIMLANE_MODE(state, { mode }) {
+ if (state.currentBoard?.settings) {
+ Vue.set(state.currentBoard.settings, 'swimlaneMode', mode)
+ }
+ },
+ SET_SWIMLANE_ORDER(state, { boardId, type, order }) {
+ const key = type === 'labels' ? 'swimlaneLabelOrder' : 'swimlaneUserOrder'
+ Vue.set(state[key], boardId, order)
+ },
TOGGLE_SHORTCUT_LOCK(state, lock) {
state.shortcutLock = lock
},
@@ -333,6 +344,20 @@ export default function storeFactory() {
const board = await apiClient.loadById(boardId)
commit('setCurrentBoard', board)
commit('setAssignableUsers', board.users)
+ if (board.settings) {
+ try {
+ const labelOrder = JSON.parse(board.settings.swimlaneLabelOrder || '[]')
+ if (labelOrder.length > 0) {
+ commit('SET_SWIMLANE_ORDER', { boardId, type: 'labels', order: labelOrder })
+ }
+ } catch (e) { /* ignore parse errors */ }
+ try {
+ const userOrder = JSON.parse(board.settings.swimlaneUserOrder || '[]')
+ if (userOrder.length > 0) {
+ commit('SET_SWIMLANE_ORDER', { boardId, type: 'assignees', order: userOrder })
+ }
+ } catch (e) { /* ignore parse errors */ }
+ }
},
async refreshBoard({ commit, dispatch }, boardId) {
@@ -529,6 +554,15 @@ export default function storeFactory() {
newOwner,
})
},
+ async setSwimlaneMode({ commit, dispatch }, { boardId, mode }) {
+ commit('SET_SWIMLANE_MODE', { mode })
+ await dispatch('setConfig', { [`board:${boardId}:swimlaneMode`]: mode })
+ },
+ async setSwimlaneOrder({ commit, dispatch }, { boardId, type, order }) {
+ const configKey = type === 'labels' ? 'swimlaneLabelOrder' : 'swimlaneUserOrder'
+ commit('SET_SWIMLANE_ORDER', { boardId, type, order })
+ await dispatch('setConfig', { [`board:${boardId}:${configKey}`]: JSON.stringify(order) })
+ },
toggleShortcutLock({ commit }, lock) {
commit('TOGGLE_SHORTCUT_LOCK', lock)
},
diff --git a/tests/unit/Service/ConfigServiceTest.php b/tests/unit/Service/ConfigServiceTest.php
new file mode 100644
index 000000000..82d2f2309
--- /dev/null
+++ b/tests/unit/Service/ConfigServiceTest.php
@@ -0,0 +1,103 @@
+config = $this->createMock(IConfig::class);
+ $this->groupManager = $this->createMock(IGroupManager::class);
+ $this->permissionService = $this->createMock(PermissionService::class);
+ $this->boardMapper = $this->createMock(BoardMapper::class);
+
+ $this->service = new ConfigService(
+ $this->config,
+ $this->groupManager,
+ $this->permissionService,
+ $this->boardMapper,
+ );
+
+ // The service lazily reads userId from IUserSession via OCP\Server::get().
+ // We bypass that by setting the private property directly so set() can run.
+ $ref = new \ReflectionProperty(ConfigService::class, 'userId');
+ $ref->setAccessible(true);
+ $ref->setValue($this->service, 'admin');
+ }
+
+ public function testSetSwimlaneModeChecksEditPermissionAndStoresShared(): void {
+ $this->permissionService->expects($this->once())
+ ->method('checkPermission')
+ ->with($this->boardMapper, 5, Acl::PERMISSION_EDIT);
+ $this->config->expects($this->once())
+ ->method('setAppValue')
+ ->with(Application::APP_ID, 'board:5:swimlaneMode', 'labels');
+ $this->config->expects($this->never())
+ ->method('setUserValue');
+
+ $result = $this->service->set('board:5:swimlaneMode', 'labels');
+ $this->assertSame('labels', $result);
+ }
+
+ public function testSetSwimlaneLabelOrderIsShared(): void {
+ $this->permissionService->expects($this->once())
+ ->method('checkPermission')
+ ->with($this->boardMapper, 7, Acl::PERMISSION_EDIT);
+ $this->config->expects($this->once())
+ ->method('setAppValue')
+ ->with(Application::APP_ID, 'board:7:swimlaneLabelOrder', '[3,1,2]');
+
+ $this->service->set('board:7:swimlaneLabelOrder', '[3,1,2]');
+ }
+
+ public function testSetSwimlaneUserOrderIsShared(): void {
+ $this->permissionService->expects($this->once())
+ ->method('checkPermission')
+ ->with($this->boardMapper, 7, Acl::PERMISSION_EDIT);
+ $this->config->expects($this->once())
+ ->method('setAppValue')
+ ->with(Application::APP_ID, 'board:7:swimlaneUserOrder', '["admin","alice"]');
+
+ $this->service->set('board:7:swimlaneUserOrder', '["admin","alice"]');
+ }
+
+ public function testSetSwimlaneModeRequiresEditPermission(): void {
+ $this->permissionService
+ ->method('checkPermission')
+ ->willThrowException(new NoPermissionException('No edit permission on board'));
+ $this->config->expects($this->never())->method('setAppValue');
+ $this->config->expects($this->never())->method('setUserValue');
+
+ $this->expectException(NoPermissionException::class);
+ $this->service->set('board:5:swimlaneMode', 'labels');
+ }
+
+ public function testNonSwimlaneBoardSettingStaysPerUser(): void {
+ // board:5:calendar is a per-user setting (no permission check, setUserValue path)
+ $this->permissionService->expects($this->never())->method('checkPermission');
+ $this->config->expects($this->never())->method('setAppValue');
+ $this->config->expects($this->once())
+ ->method('setUserValue')
+ ->with('admin', Application::APP_ID, 'board:5:calendar', 'yes');
+
+ $this->service->set('board:5:calendar', 'yes');
+ }
+}