Skip to content

Commit 20dbf48

Browse files
committed
feat: add swimlane grouping by labels or assignees
Adds an optional swimlane view that groups cards into horizontal lanes by label or assignee, with a "No label" / "Unassigned" catchall lane. Cards with multiple labels appear in every matching lane with a duplicate badge. The flat board stays the default. Implementation: - Swimlane mode and lane order are shared board settings (setAppValue), gated by PERMISSION_EDIT in ConfigController::setValue() following the pattern from #7990. ConfigService rejects non-numeric board ids on the shared path and shape-validates values (mode enum, JSON array of lane ids, max 1000 entries / 8KB) before storing. - Lane collapse state is per-user in localStorage. - New components: Swimlane.vue, SwimlaneHeader.vue. Stack.vue gained `header-only` / `hide-header` props so list headers render once in a sticky row above the lanes, sharing one horizontal scroll context. - Lanes computed reactively from cards' labels/assignees; lane order is deterministic (saved order first, ID fallback). Store mutations are optimistic with rollback on dispatch failure. Tests: PHPUnit ConfigServiceTest covers all three config keys, board id validation, value validation, and the per-user vs shared storage split. Cypress swimlanesFeatures covers grouping modes, multi-label cards, persistence across reload, and the editor-only restriction (API 403 + disabled UI controls). Closes #32 Signed-off-by: Pavlinchen <69079839+Pavlinchen@users.noreply.github.com>
1 parent b91ed4b commit 20dbf48

13 files changed

Lines changed: 1126 additions & 10 deletions

File tree

appinfo/info.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@
4242
<database min-version="9.4">pgsql</database>
4343
<database>sqlite</database>
4444
<database min-version="8.0">mysql</database>
45-
<nextcloud min-version="35" max-version="35"/>
45+
<nextcloud min-version="34" max-version="35"/>
4646
</dependencies>
4747
<background-jobs>
4848
<job>OCA\Deck\Cron\DeleteCron</job>

cypress/e2e/swimlanesFeatures.js

Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
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+
/**
10+
* Builds a board with three stacks, three labels, two cards \u2014 one card
11+
* has two labels so it should render in two lanes when grouped by labels.
12+
*/
13+
function seedSwimlaneBoard() {
14+
const auth = { user: user.userId, password: user.password }
15+
const baseUrl = Cypress.env('baseUrl')
16+
const api = `${baseUrl}/index.php/apps/deck/api/v1.0`
17+
18+
return cy.request({
19+
method: 'POST',
20+
url: `${api}/boards`,
21+
auth,
22+
body: { title: 'Swimlanes', color: '00ff00' },
23+
}).then(({ body: board }) => {
24+
const boardId = board.id
25+
26+
const stackReq = (title) => cy.request({
27+
method: 'POST',
28+
url: `${api}/boards/${boardId}/stacks`,
29+
auth,
30+
body: { title, order: 0 },
31+
})
32+
const labelReq = (title, color) => cy.request({
33+
method: 'POST',
34+
url: `${api}/boards/${boardId}/labels`,
35+
auth,
36+
body: { title, color },
37+
}).then(({ body }) => body)
38+
39+
// Cypress chainables are not promises, so the requests are chained
40+
// sequentially instead of via Promise.all
41+
const ids = {}
42+
return stackReq('Todo').then(({ body }) => {
43+
ids.todo = body.id
44+
}).then(() => stackReq('Doing'))
45+
.then(() => stackReq('Done'))
46+
.then(() => labelReq('Bug', 'ff0000'))
47+
.then((label) => {
48+
ids.bug = label.id
49+
})
50+
.then(() => labelReq('Feature', '00ff00'))
51+
.then((label) => {
52+
ids.feature = label.id
53+
})
54+
.then(() => labelReq('Backend', '0000ff'))
55+
.then((label) => {
56+
ids.backend = label.id
57+
})
58+
.then(() => {
59+
const mk = (title) => cy.request({
60+
method: 'POST',
61+
url: `${api}/boards/${boardId}/stacks/${ids.todo}/cards`,
62+
auth,
63+
body: { title, description: '' },
64+
}).then(({ body }) => body)
65+
const assignLabel = (cardId, labelId) => cy.request({
66+
method: 'PUT',
67+
url: `${api}/boards/${boardId}/stacks/${ids.todo}/cards/${cardId}/assignLabel`,
68+
auth,
69+
body: { labelId },
70+
})
71+
return mk('Fix login bug').then((card) =>
72+
// two labels on this card
73+
assignLabel(card.id, ids.bug).then(() => assignLabel(card.id, ids.backend)),
74+
).then(() =>
75+
mk('Ship feature').then((card) => assignLabel(card.id, ids.feature)),
76+
).then(() => mk('Unlabeled work'))
77+
.then(() => cy.wrap({ boardId }))
78+
})
79+
})
80+
}
81+
82+
const viewer = randUser()
83+
84+
describe('Swimlane grouping — editor-only restriction', function() {
85+
const owner = randUser()
86+
let restrictedBoardId
87+
88+
before(function() {
89+
cy.createUser(owner)
90+
cy.createUser(viewer)
91+
const ownerAuth = { user: owner.userId, password: owner.password }
92+
const base = `${Cypress.env('baseUrl')}/index.php/apps/deck/api/v1.0`
93+
cy.request({
94+
method: 'POST',
95+
url: `${base}/boards`,
96+
auth: ownerAuth,
97+
body: { title: 'Restricted board', color: '0000ff' },
98+
}).then(({ body }) => {
99+
restrictedBoardId = body.id
100+
// Share via API with all permission flags explicitly false; the
101+
// share dialog grants edit by default, which would invalidate this
102+
// "read-only" suite. PERMISSION_TYPE_USER = 0.
103+
cy.request({
104+
method: 'POST',
105+
url: `${base}/boards/${restrictedBoardId}/acl`,
106+
auth: ownerAuth,
107+
body: {
108+
type: 0,
109+
participant: viewer.userId,
110+
permissionEdit: false,
111+
permissionShare: false,
112+
permissionManage: false,
113+
},
114+
})
115+
})
116+
})
117+
118+
it('read-only user cannot set swimlaneMode via the API (403)', function() {
119+
// Clear cookies so the basic-auth credentials below aren't silently
120+
// overridden by a logged-in session left over from cy.createUser /
121+
// cy.shareBoardWithUi (which authenticate as admin and would make
122+
// the request succeed as admin, masking the permission check).
123+
cy.clearCookies()
124+
cy.request({
125+
method: 'POST',
126+
failOnStatusCode: false,
127+
url: `${Cypress.env('baseUrl')}/ocs/v2.php/apps/deck/api/v1.0/config/board:${restrictedBoardId}:swimlaneMode?format=json`,
128+
auth: { user: viewer.userId, password: viewer.password },
129+
headers: { 'OCS-APIRequest': 'true' },
130+
body: { value: 'labels' },
131+
}).then((response) => {
132+
expect(response.status).to.equal(403)
133+
})
134+
})
135+
136+
it('swimlane grouping controls are disabled in the UI for a read-only user', function() {
137+
cy.login(viewer)
138+
cy.visit(`/apps/deck/board/${restrictedBoardId}`)
139+
cy.get('button[aria-label="View Modes"]', { timeout: 10000 }).first().click()
140+
cy.get('input[name="swimlaneMode"]').each(($input) => {
141+
cy.wrap($input).should('be.disabled')
142+
})
143+
})
144+
})
145+
146+
describe('Swimlane grouping', function() {
147+
let boardId
148+
149+
before(function() {
150+
cy.createUser(user)
151+
cy.login(user)
152+
seedSwimlaneBoard().then((ctx) => { boardId = ctx.boardId })
153+
})
154+
155+
beforeEach(function() {
156+
// Reset the board to flat view before each test so the tests are
157+
// independent (done here rather than in afterEach so that a config
158+
// request still in flight when the previous test ends cannot undo it)
159+
cy.request({
160+
method: 'POST',
161+
url: `${Cypress.env('baseUrl')}/ocs/v2.php/apps/deck/api/v1.0/config/board:${boardId}:swimlaneMode?format=json`,
162+
auth: { user: user.userId, password: user.password },
163+
body: { value: 'none' },
164+
})
165+
cy.login(user)
166+
cy.visit(`/apps/deck/board/${boardId}`)
167+
cy.get('.stack', { timeout: 10000 }).should('have.length', 3)
168+
})
169+
170+
// Selects a grouping mode in the View Modes menu and waits for the
171+
// config request to be persisted, so the next test cannot race it
172+
const setModeViaMenu = (label) => {
173+
cy.intercept('POST', '**/apps/deck/api/v1.0/config/**').as('saveSwimlaneMode')
174+
cy.get('button[aria-label="View Modes"]').first().click()
175+
// {force: true} is needed because the NcActions menu has a brief
176+
// CSS visibility:hidden phase on the action-radio span while it
177+
// animates open, which is what an end user would see as a normal
178+
// click target
179+
cy.contains('.action-radio__label', label).click({ force: true })
180+
cy.wait('@saveSwimlaneMode')
181+
}
182+
183+
it('flat view shows no swimlanes initially', function() {
184+
cy.get('.swimlane').should('not.exist')
185+
cy.get('.card').should('have.length.at.least', 3)
186+
})
187+
188+
it('groups cards by labels', function() {
189+
setModeViaMenu('Labels')
190+
191+
cy.get('.swimlane', { timeout: 8000 }).should('have.length.at.least', 4)
192+
cy.get('.swimlane-header__title, .swimlane-header__label').should('contain.text', 'Bug')
193+
cy.get('.swimlane-header__title, .swimlane-header__label').should('contain.text', 'Feature')
194+
cy.get('.swimlane-header__title, .swimlane-header__label').should('contain.text', 'Backend')
195+
cy.get('.swimlane-header__title, .swimlane-header__label').last().should('contain.text', 'No label')
196+
})
197+
198+
it('renders a multi-label card in every matching lane', function() {
199+
setModeViaMenu('Labels')
200+
cy.get('.swimlane', { timeout: 8000 }).should('have.length.at.least', 4)
201+
202+
// "Fix login bug" has Bug + Backend labels \u2014 must appear in both lanes.
203+
// Use cy.contains() to avoid embedding lane names into a CSS selector string.
204+
const expectCardInLane = (laneName, cardTitle) => {
205+
cy.contains('.swimlane-header', laneName)
206+
.parents('.swimlane')
207+
.find('.card')
208+
.contains(cardTitle)
209+
.should('exist')
210+
}
211+
expectCardInLane('Bug', 'Fix login bug')
212+
expectCardInLane('Backend', 'Fix login bug')
213+
})
214+
215+
it('groups cards by assignees with Unassigned catchall last', function() {
216+
setModeViaMenu('Assignees')
217+
218+
cy.get('.swimlane', { timeout: 8000 }).should('have.length.at.least', 1)
219+
cy.get('.swimlane-header__title').last().should('contain.text', 'Unassigned')
220+
})
221+
222+
it('returns to flat view when No grouping is selected', function() {
223+
setModeViaMenu('Labels')
224+
cy.get('.swimlane', { timeout: 8000 }).should('exist')
225+
226+
setModeViaMenu('No grouping')
227+
228+
cy.get('.swimlane').should('not.exist')
229+
cy.get('.stack').should('have.length', 3)
230+
})
231+
232+
it('persists the grouping mode across reload', function() {
233+
setModeViaMenu('Labels')
234+
cy.get('.swimlane', { timeout: 8000 }).should('exist')
235+
236+
cy.reload()
237+
cy.get('.swimlane', { timeout: 10000 }).should('have.length.at.least', 4)
238+
239+
// Radio reflects the persisted mode after reload
240+
cy.get('button[aria-label="View Modes"]').first().click()
241+
cy.get('input[name="swimlaneMode"][value="labels"]').should('be.checked')
242+
})
243+
})

lib/Service/BoardService.php

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

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

lib/Service/ConfigService.php

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ class ConfigService {
2424
public const SETTING_BOARD_NOTIFICATION_DUE_ALL = 'all';
2525
public const SETTING_BOARD_NOTIFICATION_DUE_DEFAULT = self::SETTING_BOARD_NOTIFICATION_DUE_ASSIGNED;
2626

27+
private const SWIMLANE_CONFIG_KEYS = ['swimlaneMode', 'swimlaneLabelOrder', 'swimlaneUserOrder'];
28+
2729
private ?string $userId = null;
2830

2931
public function __construct(
@@ -187,16 +189,63 @@ public function set($key, $value) {
187189
if (count($parts) < 3) {
188190
break;
189191
}
192+
$boardId = $parts[1];
190193
$boardConfigKey = $parts[2];
194+
if ($boardId === '' || $boardConfigKey === '') {
195+
throw new BadRequestException('Board config key must be in the form board:<id>:<setting>');
196+
}
191197
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)) {
192198
throw new BadRequestException('Board notification option must be one of: off, assigned, all');
193199
}
194-
$this->config->setUserValue($userId, Application::APP_ID, $key, (string)$value);
200+
if (in_array($boardConfigKey, self::SWIMLANE_CONFIG_KEYS, true)) {
201+
// Shared board-level setting (visible to every board member).
202+
// EDIT permission is enforced in ConfigController::setValue(),
203+
// whose board:(\d+): match requires a numeric board id — so a
204+
// numeric id is required here too, otherwise the permission
205+
// check could be bypassed.
206+
if (!ctype_digit($boardId)) {
207+
throw new BadRequestException('Board id must be numeric');
208+
}
209+
$this->validateSwimlaneValue($boardConfigKey, $value);
210+
$this->config->setAppValue(Application::APP_ID, $key, (string)$value);
211+
} else {
212+
$this->config->setUserValue($userId, Application::APP_ID, $key, (string)$value);
213+
}
195214
$result = $value;
196215
}
197216
return $result;
198217
}
199218

219+
/**
220+
* Shared swimlane settings live in app config and are delivered to every
221+
* board member on board load, so reject malformed values before storing.
222+
*/
223+
private function validateSwimlaneValue(string $boardConfigKey, mixed $value): void {
224+
if ($boardConfigKey === 'swimlaneMode') {
225+
if (!in_array($value, ['none', 'labels', 'assignees'], true)) {
226+
throw new BadRequestException('Swimlane mode must be one of: none, labels, assignees');
227+
}
228+
return;
229+
}
230+
231+
// swimlaneLabelOrder / swimlaneUserOrder: JSON array of lane ids
232+
// (label ids, user ids or the '__none__' catch-all lane).
233+
if (!is_string($value) || strlen($value) > 8192) {
234+
throw new BadRequestException('Swimlane order must be a JSON array of lane ids');
235+
}
236+
$order = json_decode($value, true);
237+
if (!is_array($order) || !array_is_list($order) || count($order) > 1000) {
238+
throw new BadRequestException('Swimlane order must be a JSON array of lane ids');
239+
}
240+
foreach ($order as $laneId) {
241+
$isIntId = is_int($laneId);
242+
$isStringId = is_string($laneId) && $laneId !== '' && strlen($laneId) <= 64;
243+
if (!$isIntId && !$isStringId) {
244+
throw new BadRequestException('Swimlane order entries must be label ids, user ids or "__none__"');
245+
}
246+
}
247+
}
248+
200249
/**
201250
* @return string[]
202251
*/

0 commit comments

Comments
 (0)