Skip to content

Commit ceee085

Browse files
committed
feat: add swimlane grouping by labels or assignees
Add horizontal swimlane rows that group cards by label or assignee, with a "No label" / "Unassigned" catchall lane at the bottom. Cards with multiple labels appear in every matching lane with a subtle duplicate badge. Lane order is drag-reorderable and persisted as a shared board setting via ConfigService (setAppValue), gated behind PERMISSION_EDIT. - New components: Swimlane.vue, SwimlaneHeader.vue - Grouping mode selector in Controls "View Modes" menu (hidden in Gantt mode) - Store: swimlane state, cardsByStackAndLane getter, lane ordering - Backend: swimlane config keys use setAppValue (shared, not per-user) - Tests: PHPUnit ConfigServiceTest, Cypress E2E swimlanesFeatures Closes #32 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Signed-off-by: Pavlinchen <paulm.schmidt@icloud.com>
1 parent f1bcb59 commit ceee085

12 files changed

Lines changed: 880 additions & 8 deletions

File tree

cypress/e2e/swimlanesFeatures.js

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
import { randUser } from '../utils/index.js'
6+
7+
const user = randUser()
8+
9+
// Builds a board with three stacks, three labels, two cards \u2014 one card
10+
// has two labels so it should render in two lanes when grouped by labels.
11+
function seedSwimlaneBoard() {
12+
const auth = { user: user.userId, password: user.password }
13+
const baseUrl = Cypress.env('baseUrl')
14+
const api = `${baseUrl}/index.php/apps/deck/api/v1.0`
15+
16+
return cy.request({
17+
method: 'POST',
18+
url: `${api}/boards`,
19+
auth,
20+
body: { title: 'Swimlanes', color: '00ff00' },
21+
}).then(({ body: board }) => {
22+
const boardId = board.id
23+
24+
const stackReq = (title) => cy.request({
25+
method: 'POST',
26+
url: `${api}/boards/${boardId}/stacks`,
27+
auth,
28+
body: { title, order: 0 },
29+
})
30+
const labelReq = (title, color) => cy.request({
31+
method: 'POST',
32+
url: `${api}/boards/${boardId}/labels`,
33+
auth,
34+
body: { title, color },
35+
}).then(({ body }) => body)
36+
37+
return Cypress.Promise.all([
38+
stackReq('Todo'), stackReq('Doing'), stackReq('Done'),
39+
labelReq('Bug', 'ff0000'), labelReq('Feature', '00ff00'),
40+
labelReq('Backend', '0000ff'),
41+
]).then((results) => {
42+
const [todoStack, , , bug, feature, backend] = results.map((r) => r.body ?? r)
43+
const todoId = todoStack.id
44+
const mk = (title) => cy.request({
45+
method: 'POST',
46+
url: `${api}/boards/${boardId}/stacks/${todoId}/cards`,
47+
auth,
48+
body: { title, description: '' },
49+
}).then(({ body }) => body)
50+
return cy.wrap(null).then(() =>
51+
mk('Fix login bug').then((card) =>
52+
cy.request({ // two labels on this card
53+
method: 'PUT',
54+
url: `${api}/boards/${boardId}/stacks/${todoId}/cards/${card.id}/assignLabel`,
55+
auth,
56+
body: { labelId: bug.id },
57+
}).then(() => cy.request({
58+
method: 'PUT',
59+
url: `${api}/boards/${boardId}/stacks/${todoId}/cards/${card.id}/assignLabel`,
60+
auth,
61+
body: { labelId: backend.id },
62+
}))
63+
).then(() =>
64+
mk('Ship feature').then((card) =>
65+
cy.request({
66+
method: 'PUT',
67+
url: `${api}/boards/${boardId}/stacks/${todoId}/cards/${card.id}/assignLabel`,
68+
auth,
69+
body: { labelId: feature.id },
70+
})
71+
)
72+
).then(() => mk('Unlabeled work'))
73+
.then(() => cy.wrap({ boardId }))
74+
)
75+
})
76+
})
77+
}
78+
79+
describe('Swimlane grouping', function() {
80+
let boardId
81+
82+
before(function() {
83+
cy.createUser(user)
84+
cy.login(user)
85+
seedSwimlaneBoard().then((ctx) => { boardId = ctx.boardId })
86+
})
87+
88+
beforeEach(function() {
89+
cy.login(user)
90+
cy.visit(`/apps/deck/board/${boardId}`)
91+
cy.get('.stack', { timeout: 10000 }).should('have.length', 3)
92+
})
93+
94+
afterEach(function() {
95+
// Reset the board to flat view between tests so each test is independent
96+
cy.request({
97+
method: 'POST',
98+
url: `${Cypress.env('baseUrl')}/index.php/apps/deck/api/v1.0/config/board:${boardId}:swimlaneMode`,
99+
auth: { user: user.userId, password: user.password },
100+
body: { value: 'none' },
101+
})
102+
})
103+
104+
it('flat view shows no swimlanes initially', function() {
105+
cy.get('.swimlane').should('not.exist')
106+
cy.get('.card').should('have.length.at.least', 3)
107+
})
108+
109+
it('groups cards by labels', function() {
110+
cy.get('button[aria-label="View Modes"]').first().click()
111+
cy.contains('Group by labels').click()
112+
113+
cy.get('.swimlane', { timeout: 8000 }).should('have.length.at.least', 4)
114+
cy.get('.swimlane-header__title, .swimlane-header__label').should('contain.text', 'Bug')
115+
cy.get('.swimlane-header__title, .swimlane-header__label').should('contain.text', 'Feature')
116+
cy.get('.swimlane-header__title, .swimlane-header__label').should('contain.text', 'Backend')
117+
cy.get('.swimlane-header__title, .swimlane-header__label').last().should('contain.text', 'No label')
118+
})
119+
120+
it('renders a multi-label card in every matching lane', function() {
121+
cy.get('button[aria-label="View Modes"]').first().click()
122+
cy.contains('Group by labels').click()
123+
cy.get('.swimlane', { timeout: 8000 }).should('have.length.at.least', 4)
124+
125+
// "Fix login bug" has Bug + Backend labels \u2014 must appear in both lanes
126+
const titleXpath = (lane) => `.swimlane:has(.swimlane-header:contains("${lane}")) .card:contains("Fix login bug")`
127+
cy.get(titleXpath('Bug')).should('exist')
128+
cy.get(titleXpath('Backend')).should('exist')
129+
})
130+
131+
it('groups cards by assignees with Unassigned catchall last', function() {
132+
cy.get('button[aria-label="View Modes"]').first().click()
133+
cy.contains('Group by assignees').click()
134+
135+
cy.get('.swimlane', { timeout: 8000 }).should('have.length.at.least', 1)
136+
cy.get('.swimlane-header__title').last().should('contain.text', 'Unassigned')
137+
})
138+
139+
it('returns to flat view when No grouping is selected', function() {
140+
cy.get('button[aria-label="View Modes"]').first().click()
141+
cy.contains('Group by labels').click()
142+
cy.get('.swimlane', { timeout: 8000 }).should('exist')
143+
144+
cy.get('button[aria-label="View Modes"]').first().click()
145+
cy.contains('No grouping').click()
146+
147+
cy.get('.swimlane').should('not.exist')
148+
cy.get('.stack').should('have.length', 3)
149+
})
150+
151+
it('persists the grouping mode across reload', function() {
152+
cy.get('button[aria-label="View Modes"]').first().click()
153+
cy.contains('Group by labels').click()
154+
cy.get('.swimlane', { timeout: 8000 }).should('exist')
155+
156+
cy.reload()
157+
cy.get('.swimlane', { timeout: 10000 }).should('have.length.at.least', 4)
158+
})
159+
})

lib/Service/BoardService.php

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -342,9 +342,13 @@ private function applyPermissions(int $boardId, bool $edit, bool $share, bool $m
342342

343343
public function enrichWithBoardSettings(Board $board): void {
344344
$globalCalendarConfig = (bool)$this->config->getUserValue($this->userId, Application::APP_ID, 'calendar', true);
345+
$boardId = $board->getId();
345346
$settings = [
346-
'notify-due' => $this->config->getUserValue($this->userId, Application::APP_ID, 'board:' . $board->getId() . ':notify-due', ConfigService::SETTING_BOARD_NOTIFICATION_DUE_ASSIGNED),
347-
'calendar' => $this->config->getUserValue($this->userId, Application::APP_ID, 'board:' . $board->getId() . ':calendar', $globalCalendarConfig),
347+
'notify-due' => $this->config->getUserValue($this->userId, Application::APP_ID, 'board:' . $boardId . ':notify-due', ConfigService::SETTING_BOARD_NOTIFICATION_DUE_ASSIGNED),
348+
'calendar' => $this->config->getUserValue($this->userId, Application::APP_ID, 'board:' . $boardId . ':calendar', $globalCalendarConfig),
349+
'swimlaneMode' => $this->config->getAppValue(Application::APP_ID, 'board:' . $boardId . ':swimlaneMode', 'none'),
350+
'swimlaneLabelOrder' => $this->config->getAppValue(Application::APP_ID, 'board:' . $boardId . ':swimlaneLabelOrder', '[]'),
351+
'swimlaneUserOrder' => $this->config->getAppValue(Application::APP_ID, 'board:' . $boardId . ':swimlaneUserOrder', '[]'),
348352
];
349353
$board->setSettings($settings);
350354
}

lib/Service/ConfigService.php

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212

1313
use OCA\Deck\AppInfo\Application;
1414
use OCA\Deck\BadRequestException;
15+
use OCA\Deck\Db\Acl;
16+
use OCA\Deck\Db\BoardMapper;
1517
use OCA\Deck\Exceptions\FederationDisabledException;
1618
use OCA\Deck\NoPermissionException;
1719
use OCP\IConfig;
@@ -25,11 +27,15 @@ class ConfigService {
2527
public const SETTING_BOARD_NOTIFICATION_DUE_ALL = 'all';
2628
public const SETTING_BOARD_NOTIFICATION_DUE_DEFAULT = self::SETTING_BOARD_NOTIFICATION_DUE_ASSIGNED;
2729

30+
private const SWIMLANE_CONFIG_KEYS = ['swimlaneMode', 'swimlaneLabelOrder', 'swimlaneUserOrder'];
31+
2832
private ?string $userId = null;
2933

3034
public function __construct(
3135
private readonly IConfig $config,
3236
private readonly IGroupManager $groupManager,
37+
private readonly PermissionService $permissionService,
38+
private readonly BoardMapper $boardMapper,
3339
) {
3440
}
3541

@@ -183,11 +189,18 @@ public function set($key, $value) {
183189
$result = $value;
184190
break;
185191
case 'board':
186-
[$boardId, $boardConfigKey] = explode(':', $key);
192+
$parts = explode(':', $key, 3);
193+
$boardId = $parts[1] ?? '';
194+
$boardConfigKey = $parts[2] ?? '';
187195
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)) {
188196
throw new BadRequestException('Board notification option must be one of: off, assigned, all');
189197
}
190-
$this->config->setUserValue($userId, Application::APP_ID, $key, (string)$value);
198+
if (in_array($boardConfigKey, self::SWIMLANE_CONFIG_KEYS, true)) {
199+
$this->permissionService->checkPermission($this->boardMapper, (int)$boardId, Acl::PERMISSION_EDIT);
200+
$this->config->setAppValue(Application::APP_ID, $key, (string)$value);
201+
} else {
202+
$this->config->setUserValue($userId, Application::APP_ID, $key, (string)$value);
203+
}
191204
$result = $value;
192205
}
193206
return $result;

src/components/Controls.vue

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,28 @@
263263
</template>
264264
{{ showCardCover ? t('deck', 'Hide card cover images') : t('deck', 'Show card cover images') }}
265265
</NcActionButton>
266+
<template v-if="viewMode === 'kanban'">
267+
<NcActionSeparator />
268+
<NcActionCaption :name="t('deck', 'Group by')" />
269+
<NcActionRadio name="swimlaneMode"
270+
:checked="swimlaneMode === 'none'"
271+
:disabled="!canEdit"
272+
@change="setSwimlaneMode('none')">
273+
{{ t('deck', 'No grouping') }}
274+
</NcActionRadio>
275+
<NcActionRadio name="swimlaneMode"
276+
:checked="swimlaneMode === 'labels'"
277+
:disabled="!canEdit"
278+
@change="setSwimlaneMode('labels')">
279+
{{ t('deck', 'Labels') }}
280+
</NcActionRadio>
281+
<NcActionRadio name="swimlaneMode"
282+
:checked="swimlaneMode === 'assignees'"
283+
:disabled="!canEdit"
284+
@change="setSwimlaneMode('assignees')">
285+
{{ t('deck', 'Assignees') }}
286+
</NcActionRadio>
287+
</template>
266288
</NcActions>
267289
<!-- FIXME: NcActionRouter currently doesn't work as an inline action -->
268290
<NcActions v-if="isFullApp">
@@ -278,8 +300,8 @@
278300

279301
<script>
280302
import { mapState, mapGetters } from 'vuex'
281-
import { subscribe, unsubscribe } from '@nextcloud/event-bus'
282-
import { NcActions, NcActionButton, NcActionSeparator, NcAvatar, NcButton, NcPopover, NcModal } from '@nextcloud/vue'
303+
import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus'
304+
import { NcActions, NcActionButton, NcActionCaption, NcActionRadio, NcActionSeparator, NcAvatar, NcButton, NcPopover, NcModal } from '@nextcloud/vue'
283305
import labelStyle from '../mixins/labelStyle.js'
284306
import ArchiveIcon from 'vue-material-design-icons/ArchiveOutline.vue'
285307
import ImageIcon from 'vue-material-design-icons/ImageMultipleOutline.vue'
@@ -302,6 +324,9 @@ export default {
302324
NcModal,
303325
NcActions,
304326
NcActionButton,
327+
NcActionCaption,
328+
NcActionRadio,
329+
NcActionSeparator,
305330
NcButton,
306331
NcPopover,
307332
NcAvatar,
@@ -367,6 +392,9 @@ export default {
367392
labelsSorted() {
368393
return [...this.board.labels].sort((a, b) => (a.title < b.title) ? -1 : 1)
369394
},
395+
swimlaneMode() {
396+
return this.board?.settings?.swimlaneMode || 'none'
397+
},
370398
presentUsers() {
371399
if (!this.board) return []
372400
// get user object including displayname from the list of all users with acces
@@ -433,6 +461,12 @@ export default {
433461
toggleShowArchived() {
434462
this.$store.dispatch('toggleShowArchived')
435463
},
464+
setSwimlaneMode(mode) {
465+
if (this.board?.id && this.canEdit) {
466+
this.$store.dispatch('setSwimlaneMode', { boardId: this.board.id, mode })
467+
emit('deck:board:swimlane-mode-changed', mode)
468+
}
469+
},
436470
addNewStack() {
437471
this.stack = { title: this.newStackTitle }
438472
this.$store.dispatch('createStack', this.stack)

0 commit comments

Comments
 (0)