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 }} + +
+ + {{ laneCount }} +
@@ -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'); + } +}