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
159 changes: 159 additions & 0 deletions cypress/e2e/swimlanesFeatures.js
Original file line number Diff line number Diff line change
@@ -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)
})
})
8 changes: 6 additions & 2 deletions lib/Service/BoardService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
17 changes: 15 additions & 2 deletions lib/Service/ConfigService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
) {
}

Expand Down Expand Up @@ -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;
Expand Down
36 changes: 34 additions & 2 deletions src/components/Controls.vue
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,26 @@
</template>
{{ showCardCover ? t('deck', 'Hide card cover images') : t('deck', 'Show card cover images') }}
</NcActionButton>
<NcActionSeparator />
<NcActionCaption :name="t('deck', 'Group by')" />
<NcActionRadio name="swimlaneMode"
:checked="swimlaneMode === 'none'"
:disabled="!canEdit"
@change="setSwimlaneMode('none')">
{{ t('deck', 'No grouping') }}
</NcActionRadio>
<NcActionRadio name="swimlaneMode"
:checked="swimlaneMode === 'labels'"
:disabled="!canEdit"
@change="setSwimlaneMode('labels')">
{{ t('deck', 'Labels') }}
</NcActionRadio>
<NcActionRadio name="swimlaneMode"
:checked="swimlaneMode === 'assignees'"
:disabled="!canEdit"
@change="setSwimlaneMode('assignees')">
{{ t('deck', 'Assignees') }}
</NcActionRadio>
</NcActions>
<!-- FIXME: NcActionRouter currently doesn't work as an inline action -->
<NcActions v-if="isFullApp">
Expand All @@ -263,8 +283,8 @@

<script>
import { mapState, mapGetters } from 'vuex'
import { subscribe, unsubscribe } from '@nextcloud/event-bus'
import { NcActions, NcActionButton, NcAvatar, NcButton, NcPopover, NcModal } from '@nextcloud/vue'
import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus'
import { NcActions, NcActionButton, NcActionCaption, NcActionRadio, NcActionSeparator, NcAvatar, NcButton, NcPopover, NcModal } from '@nextcloud/vue'
import labelStyle from '../mixins/labelStyle.js'
import ArchiveIcon from 'vue-material-design-icons/ArchiveOutline.vue'
import ImageIcon from 'vue-material-design-icons/ImageMultipleOutline.vue'
Expand All @@ -285,6 +305,9 @@ export default {
NcModal,
NcActions,
NcActionButton,
NcActionCaption,
NcActionRadio,
NcActionSeparator,
NcButton,
NcPopover,
NcAvatar,
Expand Down Expand Up @@ -346,6 +369,9 @@ export default {
labelsSorted() {
return [...this.board.labels].sort((a, b) => (a.title < b.title) ? -1 : 1)
},
swimlaneMode() {
return this.board?.settings?.swimlaneMode || 'none'
},
presentUsers() {
if (!this.board) return []
// get user object including displayname from the list of all users with acces
Expand Down Expand Up @@ -409,6 +435,12 @@ export default {
toggleShowArchived() {
this.$store.dispatch('toggleShowArchived')
},
setSwimlaneMode(mode) {
if (this.board?.id && this.canEdit) {
this.$store.dispatch('setSwimlaneMode', { boardId: this.board.id, mode })
emit('deck:board:swimlane-mode-changed', mode)
}
},
addNewStack() {
this.stack = { title: this.newStackTitle }
this.$store.dispatch('createStack', this.stack)
Expand Down
Loading