|
| 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 | +}) |
0 commit comments