diff --git a/README.md b/README.md index 79532d3d29..c3e6ac9b81 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,70 @@ which would increase the page loading time significantly. Improvements on Nextcloud server and Deck itself will improve the situation. +## Embedding a Deck board in another Nextcloud app + +Deck provides an opt-in `nextcloud-deck-board` custom element implemented as a +Vue single-file component and registered with Vue's `defineCustomElement()`. +It reuses the +existing board UI without Deck's app navigation or browser-history router. +The normal Deck app and the reference widgets keep their existing entry points. +The element is intended for authenticated, same-instance Nextcloud pages. +Existing Deck permissions apply to every API request; passing a board ID does +not grant access or establish that the board belongs to a particular team. +The host app must validate its own team-resource context. + +Before rendering the host page, check that Deck is enabled for the current user +and dispatch its loading event: + +```php +$eventDispatcher->dispatchTyped(new \OCA\Deck\Event\LoadBoardWebComponentEvent()); +``` + +This loads `deck-boardComponent`, the Deck styles, and the attachment/editor +integrations. Do not load `deck-main` in the host page. Hosts supporting older +Deck versions must check that the event class exists and show an explicit +unavailable state when this integration is not supported. + +```html + + +``` + +Wait for `customElements.whenDefined('nextcloud-deck-board')` before relying on +the component. Vue hosts must configure this tag as a custom element. Give the +element a bounded height; the board handles its own scrolling. The component +uses light DOM to work with Nextcloud's shared styles, dialogs and viewer. +When binding IDs from Vue, use `:board-id.attr="boardId"` and +`:card-id.attr="cardId"` so a `null` card ID removes the attribute rather than +being coerced into an empty or zero-valued DOM property. +Vue manages the element's rendering, reactive attributes and lifecycle. Each +element still mounts an isolated Deck app with its own stores and memory router; +removing an element disposes that app after Vue's disconnection tick. Moving an +element within the same document preserves its mounted instance. + +- `board-id`: required positive safe integer. Changing it disposes the previous + instance and opens the new board with independent state. +- `card-id`: optional positive safe integer. Set it to open a card, or remove it + to close card details. Cards must belong to the displayed board. Opening + archived card details after the board has loaded preserves the current list + view and its active cards. +- `deck-ready`: the board and its lists have loaded; `detail` contains `boardId`. +- `deck-error`: loading, navigation, or runtime failed; `detail` contains + `boardId`, `message`, and `status` (HTTP status when available). +- `deck-navigation`: user navigation requested a board/card; `detail` contains + `boardId`, `cardId` (or `null`), and `tabId` (or `null`). + +Events bubble. The component never changes the host's URL. The host owns its +route and active navigation styling, and should mirror card navigation into +`card-id`. Cross-board links emit a navigation request but do not change the +embedded board; the host must check its context before changing `board-id`. +Removing the element disposes its Vue app, stores, live-update subscriptions +and session. The embedded sessions do not change global Axios session headers. + +Board management/sharing sidebars and global Deck keyboard shortcuts are not +part of this embed entry point. Board-level filtering, view modes, lists, card +editing and card-detail tabs use the existing Deck components. + ## Developing There are multiple ways to develop on Deck. As you will need a Nextcloud server running, the individual options are described below. @@ -100,6 +164,28 @@ You need to setup a [development environment](https://docs.nextcloud.com/server/ After the finished installation, you can clone the deck project directly in the `/[nextcloud-docker-dev-dir]/workspace/server/apps/` folder. ### Running tests +Run the frontend logic regression tests with `npm test -- --runInBand`. +The embedded board's browser lifecycle, navigation, archive and undo behavior, +and the existing non-interactive board reference entry point are tested in the Cypress E2E +suite. With a running Nextcloud test instance (using the existing `admin`/`admin` +test credentials), build the frontend and run: + +```sh +npm run dev +npx cypress run --spec cypress/e2e/embeddedBoard.js +``` + +The default test URL is `http://nextcloud.local/index.php`; set `CYPRESS_baseUrl` +to use another instance. The spec creates its own user and boards and deletes +the user afterwards. It swaps the normal Deck entry bundle for the embed or +reference bundle in the browser, reusing the authenticated page's integrations +without mounting the full Deck app. + +Cypress runs in the existing E2E CI workflow. It does not collect JavaScript +coverage; `npm run test:coverage -- --runInBand` measures the Jest logic tests +only. The Codecov configuration enables project and patch coverage checks, +but does not make Cypress E2E assertions a JavaScript coverage report. + You can use the provided Makefile to run all tests by using: make test diff --git a/cypress/e2e/embeddedBoard.js b/cypress/e2e/embeddedBoard.js new file mode 100644 index 0000000000..2c03c89297 --- /dev/null +++ b/cypress/e2e/embeddedBoard.js @@ -0,0 +1,385 @@ +/** + * 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() +const auth = { username: user.userId, password: user.password } +const tag = 'nextcloud-deck-board' +let firstBoard +let secondBoard +let activeCard +let archivedCard + +function createBoard(title, titles) { + return cy.createExampleBoard({ + user, + board: { + title, + stacks: [{ title: 'Embedded list', cards: titles.map((title) => ({ title })) }], + }, + }) +} + +function visitHost(entry = 'deck-boardComponent') { + // Reuse Deck's authenticated page and integrations, but never run deck-main. + // Optional attachment viewers are outside these board lifecycle tests. + for (const plugin of ['text-viewer.mjs', 'files_pdfviewer-main.js']) { + cy.intercept('GET', `**/${plugin}*`, { + statusCode: 200, + headers: { 'content-type': 'application/javascript' }, + body: '', + }) + } + cy.intercept('GET', '**/deck-main.js*', (request) => { + request.url = request.url.replace('deck-main.js', `${entry}.js`) + request.continue((response) => { + response.headers['cache-control'] = 'no-store' + }) + }).as('hostEntry') + cy.visit('/apps/deck') + cy.wait('@hostEntry').its('response.statusCode').should('eq', 200) + cy.get('#content').then(($content) => { + const host = $content[0].ownerDocument.createElement('section') + host.id = 'embedded-host' + host.style.cssText = 'height: 600px; width: 100%; overflow: auto' + $content[0].replaceChildren(host) + }) + cy.url().as('hostUrl') +} + +function addBoard(boardId = firstBoard.id, cardId = null, id = 'embedded-board') { + cy.window().then((win) => win.customElements.whenDefined(tag)) + return cy.document().then((document) => { + const element = document.createElement(tag) + element.id = id + element.style.cssText = 'display: block; height: 500px' + if (boardId !== null) { + element.setAttribute('board-id', String(boardId)) + } + if (cardId !== null) { + element.setAttribute('card-id', String(cardId)) + } + element.addEventListener('deck-ready', cy.stub().as(`${id}:ready`)) + element.addEventListener('deck-error', cy.stub().as(`${id}:error`)) + element.addEventListener('deck-navigation', cy.stub().as(`${id}:navigation`)) + document.querySelector('#embedded-host').append(element) + }) +} + +function expectReady(id = 'embedded-board', boardId = firstBoard.id) { + cy.get(`@${id}:ready`).should('have.been.called') + .its('lastCall.args.0.detail').should('deep.equal', { boardId }) + cy.get(`#${id} [role="status"]`).should('not.exist') +} + +function expectHostUrl() { + cy.get('@hostUrl').then((url) => cy.url().should('eq', url)) +} + +function expectCardDetails(title) { + cy.get('.modal-container .app-sidebar').should('be.visible').and('contain', title) + cy.window().should((win) => { + expect(win._nc_focus_trap?.some((trap) => trap.active)).to.eq(true) + }) +} + +function closeCard() { + cy.get('#embedded-board').then(($element) => { + $element[0].ownerDocument.activeElement?.blur() + $element[0].removeAttribute('card-id') + }) +} + +describe('Embedded Deck board', () => { + before(() => { + cy.createUser(user).its('status').should('eq', 200) + cy.login(user) + createBoard('First embedded board', ['Active embedded card', 'Archived embedded card']) + .then((board) => { + firstBoard = board + return cy.request({ + url: `${Cypress.expose('baseUrl')}/ocs/v2.php/apps/deck/api/v1.0/stacks/${board.id}?format=json`, + auth, + headers: { 'OCS-APIRequest': 'true' }, + }) + }).then(({ body }) => { + const cards = body.ocs.data.flatMap((stack) => stack.cards) + activeCard = cards.find((card) => card.title === 'Active embedded card') + archivedCard = cards.find((card) => card.title === 'Archived embedded card') + return cy.request('/csrftoken').then(({ body }) => { + return cy.request({ + method: 'PUT', + url: `${Cypress.expose('baseUrl')}/index.php/apps/deck/cards/${archivedCard.id}/archive`, + auth, + headers: { requesttoken: body.token }, + }) + }) + }).its('status').should('eq', 200) + createBoard('Second embedded board', ['Second board card']).then((board) => { + secondBoard = board + }) + }) + + beforeEach(() => { + cy.login(user) + }) + + after(() => { + cy.deleteUser(user).its('status').should('eq', 200) + }) + + it('loads a real board in light DOM without mounting the full app or navigating the host', () => { + visitHost() + addBoard() + expectReady() + cy.get('#embedded-board .board-title').should('contain', firstBoard.title) + cy.get('#embedded-board .card').should('contain', activeCard.title) + cy.get('#embedded-board').then(($element) => { + expect($element[0].shadowRoot).to.eq(null) + }) + cy.get('#content .app-navigation').should('not.exist') + cy.get('@embedded-board:navigation').should('not.have.been.called') + cy.get('@embedded-board:error').should('not.have.been.called') + expectHostUrl() + }) + + it('opens cards and tabs, emits bubbling navigation, and closes details through attributes', () => { + visitHost() + addBoard() + expectReady() + cy.document().then((document) => { + document.querySelector('#embedded-host').addEventListener('deck-navigation', cy.stub().as('hostNavigation')) + }) + cy.contains('#embedded-board .card', activeCard.title).click() + expectCardDetails(activeCard.title) + cy.get('@hostNavigation').its('lastCall.args.0.detail').should('deep.equal', { + boardId: firstBoard.id, + cardId: activeCard.id, + tabId: null, + }) + cy.get('@hostNavigation').its('lastCall.args.0.bubbles').should('eq', true) + cy.contains('.modal-container [role="tab"]', 'Comments').click() + cy.contains('.modal-container [role="tab"]', 'Comments') + .should('have.attr', 'aria-selected', 'true').blur() + cy.get('#embedded-board').then(($element) => { + $element[0].setAttribute('card-id', String(activeCard.id)) + return new Cypress.Promise((resolve) => { + $element[0].ownerDocument.defaultView.requestAnimationFrame(resolve) + }) + }) + closeCard() + cy.get('.modal-container').should('not.exist') + cy.get('@embedded-board:ready').should('have.been.calledOnce') + cy.get('@embedded-board:error').should('not.have.been.called') + expectHostUrl() + }) + + it('opens initial card attributes and updates board and card attributes together', () => { + visitHost() + addBoard(firstBoard.id, activeCard.id) + expectReady() + expectCardDetails(activeCard.title) + cy.get('#embedded-board').then(($element) => { + $element[0].setAttribute('board-id', String(secondBoard.id)) + $element[0].removeAttribute('card-id') + }) + expectReady('embedded-board', secondBoard.id) + cy.get('#embedded-board .board-title').should('contain', secondBoard.title) + cy.get('.modal-container').should('not.exist') + cy.get('@embedded-board:error').should('not.have.been.called') + expectHostUrl() + }) + + it('preserves the active card list when opening and closing archived card details', () => { + visitHost() + addBoard() + expectReady() + cy.get('#embedded-board').then(($element) => { + $element[0].setAttribute('card-id', String(archivedCard.id)) + }) + expectCardDetails(archivedCard.title) + cy.get('#embedded-board .card').should('contain', activeCard.title) + cy.get('#embedded-board .card').should('not.contain', archivedCard.title) + closeCard() + cy.get('.modal-container').should('not.exist') + cy.get('#embedded-board .card').should('contain', activeCard.title) + cy.get('@embedded-board:error').should('not.have.been.called') + }) + + it('keeps a newer card navigation when an archived request finishes late', () => { + visitHost() + addBoard() + expectReady() + cy.intercept('GET', `**/apps/deck/stacks/${firstBoard.id}/archived`, (request) => { + request.on('response', (response) => response.setDelay(300)) + }).as('archived') + cy.get('#embedded-board').then(($element) => { + $element[0].setAttribute('card-id', String(archivedCard.id)) + }) + cy.get('@archived.all').should('have.length', 1) + cy.get('#embedded-board').then(($element) => { + $element[0].setAttribute('card-id', String(activeCard.id)) + }) + cy.wait('@archived') + expectCardDetails(activeCard.title) + cy.get('#embedded-board .card').should('contain', activeCard.title) + cy.get('@embedded-board:error').should('not.have.been.called') + }) + + it('moves an element without remounting and reconnects a removed element with its new attributes', () => { + visitHost() + addBoard() + expectReady() + cy.get('#embedded-board').then(($element) => { + const parent = $element[0].ownerDocument.createElement('div') + $element[0].parentNode.append(parent) + parent.append($element[0]) + }) + cy.get('#embedded-board .card').should('contain', activeCard.title) + cy.get('@embedded-board:ready').should('have.been.calledOnce') + cy.get('#embedded-board').then(($element) => { + cy.wrap($element[0]).as('detachedBoard') + $element[0].remove() + }) + cy.get('#embedded-board').should('not.exist') + cy.get('@detachedBoard').then(($element) => { + const element = $element[0] + element.setAttribute('board-id', String(secondBoard.id)) + element.ownerDocument.querySelector('#embedded-host').append(element) + }) + expectReady('embedded-board', secondBoard.id) + cy.get('#embedded-board .card').should('contain', 'Second board card') + cy.get('@embedded-board:ready').should('have.been.calledTwice') + }) + + it('isolates filtering and removal between multiple boards', () => { + visitHost() + addBoard() + addBoard(secondBoard.id, null, 'second-board') + expectReady() + expectReady('second-board', secondBoard.id) + cy.get('#embedded-board .board-search input').type('No matching card') + cy.get('#embedded-board .card').should('not.exist') + cy.get('#second-board .card').should('contain', 'Second board card') + cy.get('#embedded-board').then(($element) => $element[0].remove()) + cy.get('#second-board .card').should('contain', 'Second board card') + cy.get('@second-board:ready').should('have.been.calledOnce') + cy.get('@second-board:error').should('not.have.been.called') + }) + + it('restores a deleted card only in its original board even after another board mounts', () => { + visitHost() + addBoard() + expectReady() + cy.intercept('DELETE', `**/apps/deck/cards/${activeCard.id}`).as('deleteCard') + cy.intercept('PUT', `**/apps/deck/api/v1.0/cards/${activeCard.id}`).as('restoreCard') + cy.contains('#embedded-board .card', activeCard.title).find('.action-item__menutoggle').click() + cy.contains('.v-popper__popper button', 'Delete card').click() + cy.wait('@deleteCard').its('response.statusCode').should('eq', 200) + cy.contains('#embedded-board .card', activeCard.title).should('not.exist') + addBoard(secondBoard.id, null, 'second-board') + expectReady('second-board', secondBoard.id) + cy.contains('button', 'Undo').click() + cy.wait('@restoreCard').its('response.statusCode').should('eq', 200) + cy.get('#embedded-board .card').should('contain', activeCard.title) + cy.get('#second-board .card').should('contain', 'Second board card') + cy.get('#second-board .card').should('not.contain', activeCard.title) + }) + + it('reports invalid attributes and recovers when valid attributes are supplied', () => { + visitHost() + addBoard('0') + cy.get('#embedded-board [role="alert"]').should('be.visible') + cy.get('@embedded-board:error').its('lastCall.args.0.detail') + .should('include', { boardId: '0', status: null }) + cy.get('@embedded-board:ready').should('not.have.been.called') + cy.get('#embedded-board').then(($element) => { + $element[0].setAttribute('board-id', String(firstBoard.id)) + }) + expectReady() + cy.get('#embedded-board [role="alert"]').should('not.exist') + cy.get('#embedded-board .card').should('contain', activeCard.title) + }) + + it('reports HTTP failures with the original status', () => { + visitHost() + cy.intercept('GET', `**/apps/deck/api/v1.0/board/${firstBoard.id}*`, { + statusCode: 403, + body: { message: 'Access denied' }, + }) + addBoard() + cy.get('#embedded-board [role="alert"]').should('be.visible') + cy.get('@embedded-board:error').its('lastCall.args.0.detail.status').should('eq', 403) + cy.get('@embedded-board:ready').should('not.have.been.called') + }) + + it('ignores late failures from a board replaced during loading', () => { + visitHost() + cy.intercept('GET', `**/apps/deck/api/v1.0/board/${firstBoard.id}*`, { + statusCode: 403, + body: { message: 'Late access denial' }, + delay: 300, + }).as('oldBoard') + addBoard() + cy.get('@oldBoard.all').should('have.length', 1) + cy.get('#embedded-board').then(($element) => { + $element[0].setAttribute('board-id', String(secondBoard.id)) + }) + cy.wait('@oldBoard') + expectReady('embedded-board', secondBoard.id) + cy.get('#embedded-board .card').should('contain', 'Second board card') + cy.get('@embedded-board:error').should('not.have.been.called') + }) + + it('does not emit ready or mount a board removed during startup', () => { + visitHost() + cy.intercept('GET', `**/apps/deck/api/v1.0/board/${firstBoard.id}*`, (request) => { + request.on('response', (response) => response.setDelay(300)) + }).as('loadingBoard') + addBoard() + cy.get('@loadingBoard.all').should('have.length', 1) + cy.get('#embedded-board').then(($element) => $element[0].remove()) + cy.wait('@loadingBoard') + cy.get('#embedded-host .board-wrapper').should('not.exist') + cy.get('@embedded-board:ready').should('not.have.been.called') + cy.get('@embedded-board:error').should('not.have.been.called') + }) + + it('reports cards outside the displayed board instead of opening them', () => { + visitHost() + addBoard(secondBoard.id) + expectReady('embedded-board', secondBoard.id) + cy.get('#embedded-board').then(($element) => { + $element[0].setAttribute('card-id', String(activeCard.id)) + }) + cy.get('#embedded-board [role="alert"]').should('be.visible') + cy.get('@embedded-board:error').should('have.been.called') + cy.get('.modal-container').should('not.exist') + }) + + it('keeps the existing non-interactive board reference entry point', () => { + visitHost('deck-reference') + cy.window().should((win) => { + expect(win._vue_richtext_widgets['deck-board']).to.exist + }).then((win) => { + const host = win.document.querySelector('#embedded-host') + const widget = win._vue_richtext_widgets['deck-board'] + const element = win.document.createElement('div') + element.id = 'board-reference' + host.append(element) + widget.callback(element, { + richObjectType: 'deck-board', + richObject: { board: firstBoard }, + accessible: true, + interactive: false, + }) + }) + cy.get('#board-reference a').should('contain', firstBoard.title) + .and('have.attr', 'href').and('include', `/board/${firstBoard.id}`) + cy.get('#board-reference .board-wrapper').should('not.exist') + expectHostUrl() + }) +}) diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index fa1df26ff0..8ad51b03da 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -25,6 +25,7 @@ use OCA\Deck\Event\CardCreatedEvent; use OCA\Deck\Event\CardDeletedEvent; use OCA\Deck\Event\CardUpdatedEvent; +use OCA\Deck\Event\LoadBoardWebComponentEvent; use OCA\Deck\Event\SessionClosedEvent; use OCA\Deck\Event\SessionCreatedEvent; use OCA\Deck\Federation\DeckFederationProvider; @@ -33,6 +34,7 @@ use OCA\Deck\Listeners\CommentEventListener; use OCA\Deck\Listeners\FullTextSearchEventListener; use OCA\Deck\Listeners\LiveUpdateListener; +use OCA\Deck\Listeners\LoadBoardWebComponentListener; use OCA\Deck\Listeners\ParticipantCleanupListener; use OCA\Deck\Listeners\ResourceAdditionalScriptsListener; use OCA\Deck\Listeners\ResourceListener; @@ -147,6 +149,7 @@ public function register(IRegistrationContext $context): void { $context->registerReferenceProvider(CommentReferenceProvider::class); $context->registerEventListener(BeforeTemplateRenderedEvent::class, BeforeTemplateRenderedListener::class); + $context->registerEventListener(LoadBoardWebComponentEvent::class, LoadBoardWebComponentListener::class); $context->registerEventListener(ResourceTypeRegisterEvent::class, ResourceTypeRegisterListener::class); // Event listening to emit UserShareAccessUpdatedEvent for files_sharing diff --git a/lib/Event/LoadBoardWebComponentEvent.php b/lib/Event/LoadBoardWebComponentEvent.php new file mode 100644 index 0000000000..4e7082434a --- /dev/null +++ b/lib/Event/LoadBoardWebComponentEvent.php @@ -0,0 +1,15 @@ + */ +class LoadBoardWebComponentListener implements IEventListener { + public function __construct( + private IInitialState $initialState, + private IEventDispatcher $eventDispatcher, + ) { + } + + public function handle(Event $event): void { + if (!$event instanceof LoadBoardWebComponentEvent) { + return; + } + + $this->initialState->provideInitialState('maxUploadSize', (int)Util::uploadLimit()); + Util::addStyle('deck', 'deck'); + Util::addScript('deck', 'deck-boardComponent'); + $this->eventDispatcher->dispatchTyped(new LoadSidebar()); + $this->eventDispatcher->dispatchTyped(new LoadAdditionalScriptsEvent()); + if (class_exists(LoadEditor::class)) { + $this->eventDispatcher->dispatchTyped(new LoadEditor()); + } + if (class_exists(LoadViewer::class)) { + $this->eventDispatcher->dispatchTyped(new LoadViewer()); + } + } +} diff --git a/package.json b/package.json index b4a32dbb04..4d7825cad8 100644 --- a/package.json +++ b/package.json @@ -104,9 +104,12 @@ "^@/(.*)$": "/src/$1" }, "transform": { - "^.+\\.js$": "/node_modules/babel-jest", + "^.+\\.[cm]?js$": "/node_modules/babel-jest", ".*\\.(vue)$": "/node_modules/@vue/vue3-jest" }, + "transformIgnorePatterns": [ + "node_modules/(?!(nostics|pinia)/)" + ], "snapshotSerializers": [ "/node_modules/jest-serializer-vue" ] diff --git a/src/CardMoveDialog.vue b/src/CardMoveDialog.vue index c7d3246203..d0435757de 100644 --- a/src/CardMoveDialog.vue +++ b/src/CardMoveDialog.vue @@ -48,6 +48,10 @@ import { useStackStore } from './stores/stack.js' export default { name: 'CardMoveDialog', components: { NcDialog, NcSelect, NcButton }, + inject: { + deckEmbedding: { default: null }, + }, + data() { return { card: null, @@ -81,11 +85,15 @@ export default { }, mounted() { - subscribe('deck:card:show-move-dialog', this.openModal) + if (!this.deckEmbedding) { + subscribe('deck:card:show-move-dialog', this.openModal) + } }, unmounted() { - unsubscribe('deck:card:show-move-dialog', this.openModal) + if (!this.deckEmbedding) { + unsubscribe('deck:card:show-move-dialog', this.openModal) + } }, methods: { diff --git a/src/components/Controls.vue b/src/components/Controls.vue index 2c09e1fbe7..e7af87e32e 100644 --- a/src/components/Controls.vue +++ b/src/components/Controls.vue @@ -366,6 +366,11 @@ export default { }, mixins: [labelStyle], + + inject: { + deckEmbedding: { default: null }, + }, + props: { board: { type: Object, @@ -404,7 +409,7 @@ export default { filter: { tags: [], users: [], due: '', unassigned: false, completed: 'both' }, showAddCardModal: false, defaultPageTitle: false, - isNotifyPushEnabled: isNotifyPushEnabled(), + isNotifyPushEnabled: this.deckEmbedding?.pushEnabled ?? isNotifyPushEnabled(), } }, @@ -475,6 +480,9 @@ export default { }, beforeMount() { + if (this.deckEmbedding) { + return + } subscribe('deck:board:show-new-card', this.clickShowAddCardModel) subscribe('deck:board:toggle-filter-popover', this.triggerOpenFilters) subscribe('deck:board:clear-filter', this.triggerClearFilter) @@ -482,6 +490,9 @@ export default { }, beforeUnmount() { + if (this.deckEmbedding) { + return + } unsubscribe('deck:board:show-new-card', this.clickShowAddCardModel) unsubscribe('deck:board:toggle-filter-popover', this.triggerOpenFilters) unsubscribe('deck:board:clear-filter', this.triggerClearFilter) diff --git a/src/components/board/Board.spec.js b/src/components/board/Board.spec.js new file mode 100644 index 0000000000..c398a7f2fe --- /dev/null +++ b/src/components/board/Board.spec.js @@ -0,0 +1,103 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { beforeEach, expect, jest, test } from '@jest/globals' +import { subscribe, unsubscribe } from '@nextcloud/event-bus' +import Board from './Board.vue' +import { createSession } from '../../sessions.js' + +jest.mock('@nextcloud/event-bus', () => ({ subscribe: jest.fn(), unsubscribe: jest.fn() }), { virtual: true }) +jest.mock('@nextcloud/vue', () => ({}), { virtual: true }) +jest.mock('pinia', () => ({ mapActions: () => ({}), mapState: () => ({}) })) +jest.mock('vue3-smooth-dnd', () => ({})) +jest.mock('vue-material-design-icons/Check.vue', () => ({})) +jest.mock('../card/CardSidebar.vue', () => ({})) +jest.mock('../Controls.vue', () => ({})) +jest.mock('../icons/DeckIcon.vue', () => ({})) +jest.mock('../search/GlobalSearchResults.vue', () => ({})) +jest.mock('./GanttView.vue', () => ({})) +jest.mock('./Stack.vue', () => ({})) +jest.mock('../../logger.js', () => ({ error: jest.fn() })) +jest.mock('../../helpers/errors.js', () => ({ showError: jest.fn() })) +jest.mock('../../sessions.js', () => ({ createSession: jest.fn() })) +jest.mock('../../stores/board.js', () => ({ useBoardStore: jest.fn() })) +jest.mock('../../stores/card.js', () => ({ useCardStore: jest.fn() })) +jest.mock('../../stores/settings.js', () => ({ useSettingsStore: jest.fn() })) +jest.mock('../../stores/stack.js', () => ({ useStackStore: jest.fn() })) + +beforeEach(() => jest.clearAllMocks()) + +function viewModel() { + return { + id: 42, + board: { id: 42 }, + disposed: false, + deckEmbedding: null, + loadBoardById: jest.fn().mockResolvedValue(), + loadStacks: jest.fn().mockResolvedValue(), + $emit: jest.fn(), + $route: { params: {} }, + stopMouseDrag: jest.fn(), + } +} + +test('normal board still loads through the legacy session path', async () => { + const vm = viewModel() + await Board.methods.fetchData.call(vm) + expect(vm.loadBoardById).toHaveBeenCalledWith(42) + expect(vm.loadStacks).toHaveBeenCalledWith(42) + expect(createSession).toHaveBeenCalledWith(42) + expect(vm.$emit).toHaveBeenCalledWith('ready', vm.board) + expect(vm.loading).toBe(false) +}) + +test('embedded board uses its own session provider', async () => { + const vm = viewModel() + vm.deckEmbedding = { createSession: jest.fn() } + await Board.methods.fetchData.call(vm) + expect(vm.deckEmbedding.createSession).toHaveBeenCalledWith(42) + expect(createSession).not.toHaveBeenCalled() +}) + +test('load failures keep the existing error path and emit error', async () => { + const vm = viewModel() + const error = new Error('Denied') + vm.loadBoardById.mockRejectedValue(error) + await Board.methods.fetchData.call(vm) + expect(vm.$emit).toHaveBeenCalledWith('error', error) + expect(vm.$emit).not.toHaveBeenCalledWith('ready', vm.board) + expect(vm.loading).toBe(false) + expect(createSession).not.toHaveBeenCalled() +}) + +test('unmounted board cannot create a late session', async () => { + const vm = viewModel() + vm.loadBoardById.mockImplementation(async () => { + vm.disposed = true + }) + await Board.methods.fetchData.call(vm) + expect(vm.loadStacks).not.toHaveBeenCalled() + expect(createSession).not.toHaveBeenCalled() +}) + +test('legacy boards retain their event-bus subscription', () => { + const vm = viewModel() + vm.fetchData = jest.fn() + vm.openCardModal = jest.fn() + Board.created.call(vm) + expect(subscribe).toHaveBeenCalledWith('deck:card:open-modal', vm.openCardModal) + Board.beforeUnmount.call(vm) + expect(unsubscribe).toHaveBeenCalledWith('deck:card:open-modal', vm.openCardModal) +}) + +test('embedded boards do not listen to global card-opening events', () => { + const vm = viewModel() + vm.deckEmbedding = {} + vm.fetchData = jest.fn() + Board.created.call(vm) + Board.beforeUnmount.call(vm) + expect(subscribe).not.toHaveBeenCalled() + expect(unsubscribe).not.toHaveBeenCalled() +}) diff --git a/src/components/board/Board.vue b/src/components/board/Board.vue index 0bda17df35..b54f9ae60b 100644 --- a/src/components/board/Board.vue +++ b/src/components/board/Board.vue @@ -134,9 +134,10 @@ export default { CardSidebar, }, - inject: [ - 'boardApi', - ], + inject: { + boardApi: {}, + deckEmbedding: { default: null }, + }, props: { id: { @@ -145,6 +146,8 @@ export default { }, }, + emits: ['ready', 'error'], + data() { return { draggingStack: false, @@ -153,6 +156,7 @@ export default { currentScrollPosX: null, currentMousePosX: null, localModal: null, + disposed: false, } }, @@ -209,12 +213,20 @@ export default { created() { // Session is created in fetchData() after loadBoardById succeeds this.fetchData() - subscribe('deck:card:open-modal', this.openCardModal) + if (!this.deckEmbedding) { + subscribe('deck:card:open-modal', this.openCardModal) + } }, beforeUnmount() { - this.session?.close() - unsubscribe('deck:card:open-modal', this.openCardModal) + this.disposed = true + this.stopMouseDrag() + this.session?.close().catch((error) => { + logger.error('Failed to close board session', { error }) + }) + if (!this.deckEmbedding) { + unsubscribe('deck:card:open-modal', this.openCardModal) + } }, methods: { @@ -228,6 +240,9 @@ export default { this.loading = true try { await this.loadBoardById(this.id) + if (this.disposed) { + return + } await this.loadStacks(this.id) const routeCardId = this.$route?.params?.cardId ? parseInt(this.$route.params.cardId) : null @@ -240,12 +255,22 @@ export default { } } - this.session?.close() - this.session = createSession(this.id) + if (this.disposed) { + return + } + await this.session?.close() + if (this.disposed) { + return + } + this.session = this.deckEmbedding + ? this.deckEmbedding.createSession(this.id) + : createSession(this.id) + this.$emit('ready', this.board) } catch (e) { this.loading = false logger.error('Failed to load board', { error: e }) showError(e) + this.$emit('error', e) } finally { this.loading = false } diff --git a/src/components/cards/NcActionsCardMenuEntries.spec.js b/src/components/cards/NcActionsCardMenuEntries.spec.js new file mode 100644 index 0000000000..2823e27a68 --- /dev/null +++ b/src/components/cards/NcActionsCardMenuEntries.spec.js @@ -0,0 +1,68 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { beforeEach, expect, jest, test } from '@jest/globals' +import { showUndo } from '@nextcloud/dialogs' +import { createPinia, defineStore, setActivePinia } from 'pinia' +import NcActionsCardMenuEntries from './NcActionsCardMenuEntries.vue' +import { useTrashbinStore } from '../../stores/trashbin.js' + +jest.mock('@vue/devtools-api', () => ({ setupDevtoolsPlugin: jest.fn() })) +jest.mock('@nextcloud/auth', () => ({}), { virtual: true }) +jest.mock('@nextcloud/dialogs', () => ({ showUndo: jest.fn() }), { virtual: true }) +jest.mock('@nextcloud/dialogs/style.css', () => ({}), { virtual: true }) +jest.mock('@nextcloud/event-bus', () => ({}), { virtual: true }) +jest.mock('@nextcloud/router', () => ({}), { virtual: true }) +jest.mock('@nextcloud/vue', () => ({}), { virtual: true }) +jest.mock('vue-material-design-icons/AccountMinusOutline.vue', () => ({})) +jest.mock('vue-material-design-icons/AccountPlusOutline.vue', () => ({})) +jest.mock('vue-material-design-icons/ArchiveOutline.vue', () => ({})) +jest.mock('vue-material-design-icons/CardBulletedOutline.vue', () => ({})) +jest.mock('vue-material-design-icons/Check.vue', () => ({})) +jest.mock('vue-material-design-icons/Circle.vue', () => ({})) +jest.mock('vue-material-design-icons/Delete.vue', () => ({})) +jest.mock('vue-material-design-icons/OpenInNew.vue', () => ({})) +jest.mock('vue-material-design-icons/PencilOutline.vue', () => ({})) +jest.mock('../../stores/actions.js', () => ({})) +jest.mock('../../stores/board.js', () => ({})) +jest.mock('../../stores/card.js', () => ({})) +jest.mock('../../stores/stack.js', () => ({})) +jest.mock('../../stores/trashbin.js', () => ({ useTrashbinStore: jest.fn() })) + +beforeEach(() => jest.clearAllMocks()) + +test('undo uses the deleting component store even when another board is active', () => { + const trashStore = defineStore('undo-test', { + actions: { cardUndoDelete: jest.fn() }, + }) + useTrashbinStore.mockImplementation(trashStore) + const first = createPinia() + const second = createPinia() + const firstTrash = trashStore(first) + const secondTrash = trashStore(second) + firstTrash.cardUndoDelete = jest.fn() + secondTrash.cardUndoDelete = jest.fn() + setActivePinia(second) + globalThis.t = (_, text) => text + try { + const card = { id: 7, stackId: 1 } + const vm = { + $pinia: first, + card, + boardId: 42, + deleteCardInStore: jest.fn(), + $router: { currentRoute: { name: 'board' } }, + } + NcActionsCardMenuEntries.methods.deleteCard.call(vm) + expect(vm.deleteCardInStore).toHaveBeenCalledWith(card) + expect(useTrashbinStore).toHaveBeenCalledWith(first) + setActivePinia(second) + showUndo.mock.calls[0][1]() + expect(firstTrash.cardUndoDelete).toHaveBeenCalledWith({ ...card, deletedAt: 0, boardId: 42 }) + expect(secondTrash.cardUndoDelete).not.toHaveBeenCalled() + } finally { + delete globalThis.t + } +}) diff --git a/src/components/cards/NcActionsCardMenuEntries.vue b/src/components/cards/NcActionsCardMenuEntries.vue index 3ff6bb79aa..3b2a734491 100644 --- a/src/components/cards/NcActionsCardMenuEntries.vue +++ b/src/components/cards/NcActionsCardMenuEntries.vue @@ -115,6 +115,10 @@ import '@nextcloud/dialogs/style.css' export default { name: 'NcActionsCardMenuEntries', components: { NcColorPicker, NcActionButton, ArchiveIcon, CardBulletedIcon, PencilIcon, SelectColor, AccountPlusIcon, AccountMinusIcon, CheckIcon, OpenInNewIcon, DeleteIcon }, + inject: { + deckEmbedding: { default: null }, + }, + props: { card: { type: Object, @@ -221,9 +225,10 @@ export default { }, deleteCard() { + const trashbin = useTrashbinStore(this.$pinia) this.deleteCardInStore(this.card) const undoCard = { ...this.card, deletedAt: 0, boardId: this.boardId } - showUndo(t('deck', 'Card deleted'), () => useTrashbinStore().cardUndoDelete(undoCard)) + showUndo(t('deck', 'Card deleted'), () => trashbin.cardUndoDelete(undoCard)) if (this.$router.currentRoute.name === 'card') { this.$router.push({ name: 'board' }) } @@ -258,7 +263,11 @@ export default { }, openCardMoveDialog() { - emit('deck:card:show-move-dialog', this.card) + if (this.deckEmbedding) { + this.deckEmbedding.openMoveDialog(this.card) + } else { + emit('deck:card:show-move-dialog', this.card) + } }, openColorPicker() { diff --git a/src/embedding/BoardElement.js b/src/embedding/BoardElement.js new file mode 100644 index 0000000000..982fee7890 --- /dev/null +++ b/src/embedding/BoardElement.js @@ -0,0 +1,24 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { defineCustomElement } from 'vue' +import BoardElement from './BoardElement.vue' + +export { parseId } from './BoardElement.vue' + +export const BOARD_ELEMENT_NAME = 'nextcloud-deck-board' + +export function registerBoardElement(mountBoard) { + if (customElements.get(BOARD_ELEMENT_NAME)) { + return + } + + customElements.define(BOARD_ELEMENT_NAME, defineCustomElement(BoardElement, { + shadowRoot: false, + configureApp(app) { + app.provide('mountDeckBoard', mountBoard) + }, + })) +} diff --git a/src/embedding/BoardElement.spec.js b/src/embedding/BoardElement.spec.js new file mode 100644 index 0000000000..29569c90e4 --- /dev/null +++ b/src/embedding/BoardElement.spec.js @@ -0,0 +1,19 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, jest, test } from '@jest/globals' +import { parseId } from './BoardElement.vue' + +jest.mock('@nextcloud/l10n', () => ({ translate: (_, text) => text }), { virtual: true }) +jest.mock('../logger.js', () => ({ error: jest.fn() })) + +test.each([null, undefined, '', '0', '-1', '1.5', '1e2', ' 42', '9007199254740992'])('rejects invalid ID %s', (value) => { + expect(() => parseId(value)).toThrow() +}) + +test('accepts positive safe integer IDs', () => { + expect(parseId('42')).toBe(42) + expect(parseId(String(Number.MAX_SAFE_INTEGER))).toBe(Number.MAX_SAFE_INTEGER) +}) diff --git a/src/embedding/BoardElement.vue b/src/embedding/BoardElement.vue new file mode 100644 index 0000000000..3183907f73 --- /dev/null +++ b/src/embedding/BoardElement.vue @@ -0,0 +1,192 @@ + + + + + + + diff --git a/src/embedding/EmbeddedBoard.vue b/src/embedding/EmbeddedBoard.vue new file mode 100644 index 0000000000..367e1f8b77 --- /dev/null +++ b/src/embedding/EmbeddedBoard.vue @@ -0,0 +1,114 @@ + + + + + + + diff --git a/src/embedding/mountBoard.js b/src/embedding/mountBoard.js new file mode 100644 index 0000000000..896daaf061 --- /dev/null +++ b/src/embedding/mountBoard.js @@ -0,0 +1,139 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import axios from '@nextcloud/axios' +import { translate, translatePlural } from '@nextcloud/l10n' +import { generateOcsUrl } from '@nextcloud/router' +import { createPinia, disposePinia } from 'pinia' +import { createApp } from 'vue' +import EmbeddedBoard from './EmbeddedBoard.vue' +import logger from '../logger.js' +import { BoardApi } from '../services/BoardApi.js' +import { createSession } from '../sessions.js' +import { useBoardStore } from '../stores/board.js' +import { useSettingsStore } from '../stores/settings.js' +import { useStackStore } from '../stores/stack.js' +import { registerBoardUpdates } from './push.js' +import { createBoardRouter } from './router.js' + +export async function mountBoard(element, { boardId, cardId, signal, onReady, onError, onNavigate }) { + const pinia = createPinia() + const router = createBoardRouter(boardId, onNavigate) + const app = createApp(EmbeddedBoard, { boardId, onReady, onError }) + app.use(pinia) + app.config.globalProperties.t = translate + app.config.globalProperties.n = translatePlural + app.config.globalProperties.OC = window.OC + app.config.globalProperties.OCA = window.OCA + app.config.errorHandler = (error) => { + logger.error('Embedded Deck component failed', { error }) + onError(error) + } + app.directive('focus', { mounted: (el) => el.focus() }) + app.provide('boardApi', new BoardApi()) + + const board = useBoardStore(pinia) + const settings = useSettingsStore(pinia) + const stacks = useStackStore(pinia) + board.setBoards([]) + settings.setFullApp(false) + let mounted = false + let disposed = false + let updates + let stopNavigation + const unmount = () => { + if (disposed) { + return + } + disposed = true + stopNavigation?.() + updates?.dispose() + if (mounted) { + app.unmount() + } + disposePinia(pinia) + signal.removeEventListener('abort', unmount) + } + signal.addEventListener('abort', unmount, { once: true }) + + const cardRoute = (id) => ({ + name: id ? 'card' : 'board', + params: { id: boardId, ...(id ? { cardId: id } : {}) }, + }) + try { + if (signal.aborted) { + unmount() + return null + } + const response = await axios.get(generateOcsUrl('apps/deck/api/v1.0/config'), { signal }) + if (signal.aborted) { + return null + } + settings.config = response.data.ocs.data + await board.loadBoards() + if (signal.aborted) { + return null + } + await router.replace(cardRoute(cardId)) + await router.isReady() + if (signal.aborted) { + return null + } + app.use(router) + + const report = (error) => { + if (!disposed) { + logger.error('Failed to update embedded Deck board', { error }) + onError(error) + } + } + updates = registerBoardUpdates({ + boardId, + refreshBoard: () => { + if (board.currentBoard) { + board.refreshBoard(boardId).catch(report) + } + }, + refreshCards: () => { + if (board.currentBoard) { + stacks.loadStacks(boardId).catch(report) + } + }, + }) + app.provide('deckEmbedding', { + pushEnabled: updates.pushEnabled, + createSession: (id) => createSession(id, { + pushEnabled: updates.pushEnabled, + boardStore: board, + globalHeader: false, + onError: report, + }), + }) + stopNavigation = router.afterEach((to, from, failure) => { + if (!failure && to.fullPath !== from.fullPath) { + onNavigate({ + boardId, + cardId: to.params.cardId ? Number(to.params.cardId) : null, + tabId: to.params.tabId ?? null, + }) + } + }) + mounted = true + app.mount(element) + return { + unmount, + setCard: (id) => { + const currentId = router.currentRoute.value.params.cardId + if ((currentId ? Number(currentId) : null) === id) { + return Promise.resolve() + } + return router.replace(cardRoute(id)) + }, + } + } catch (error) { + unmount() + throw error + } +} diff --git a/src/embedding/push.js b/src/embedding/push.js new file mode 100644 index 0000000000..77c6943f4d --- /dev/null +++ b/src/embedding/push.js @@ -0,0 +1,36 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { listen } from '@nextcloud/notify_push' + +// notify_push has no unsubscribe API; register once and retain only live embeds. +const contexts = new Set() +let initialized = false +let pushEnabled = false + +export function registerBoardUpdates(context) { + if (!initialized) { + initialized = true + pushEnabled = listen('deck_board_update', (_, event) => { + for (const active of contexts) { + if (event.id === active.boardId) { + active.refreshBoard() + } + } + }) + listen('deck_card_update', (_, event) => { + for (const active of contexts) { + if (event.boardId === active.boardId) { + active.refreshCards() + } + } + }) + } + contexts.add(context) + return { + pushEnabled, + dispose: () => contexts.delete(context), + } +} diff --git a/src/embedding/push.spec.js b/src/embedding/push.spec.js new file mode 100644 index 0000000000..f6d3257c14 --- /dev/null +++ b/src/embedding/push.spec.js @@ -0,0 +1,32 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, jest, test } from '@jest/globals' +import { listen } from '@nextcloud/notify_push' +import { registerBoardUpdates } from './push.js' + +jest.mock('@nextcloud/notify_push', () => ({ listen: jest.fn().mockReturnValue(true) }), { virtual: true }) + +test('registers once and only refreshes live matching instances', () => { + const first = { boardId: 42, refreshBoard: jest.fn(), refreshCards: jest.fn() } + const second = { boardId: 43, refreshBoard: jest.fn(), refreshCards: jest.fn() } + const a = registerBoardUpdates(first) + const b = registerBoardUpdates(second) + expect(a.pushEnabled).toBe(true) + expect(listen).toHaveBeenCalledTimes(2) + const boardUpdate = listen.mock.calls[0][1] + const cardUpdate = listen.mock.calls[1][1] + boardUpdate('deck_board_update', { id: 42 }) + cardUpdate('deck_card_update', { boardId: 43 }) + expect(first.refreshBoard).toHaveBeenCalledTimes(1) + expect(second.refreshBoard).not.toHaveBeenCalled() + expect(second.refreshCards).toHaveBeenCalledTimes(1) + a.dispose() + boardUpdate('deck_board_update', { id: 42 }) + expect(first.refreshBoard).toHaveBeenCalledTimes(1) + b.dispose() + cardUpdate('deck_card_update', { boardId: 43 }) + expect(second.refreshCards).toHaveBeenCalledTimes(1) +}) diff --git a/src/embedding/router.js b/src/embedding/router.js new file mode 100644 index 0000000000..0913659720 --- /dev/null +++ b/src/embedding/router.js @@ -0,0 +1,30 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { createMemoryHistory, createRouter } from 'vue-router' + +const EmptyView = { render: () => null } + +export function createBoardRouter(boardId, onNavigate) { + const router = createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/', redirect: { name: 'board', params: { id: boardId } } }, + { path: '/board/:id', name: 'board', component: EmptyView }, + { path: '/board/:id/card/:cardId/:tabId?/:tabQuery?', name: 'card', component: EmptyView }, + ], + }) + router.beforeEach((to) => { + if (Number(to.params.id) !== boardId) { + onNavigate({ + boardId: Number(to.params.id), + cardId: to.params.cardId ? Number(to.params.cardId) : null, + tabId: to.params.tabId ?? null, + }) + return false + } + }) + return router +} diff --git a/src/embedding/router.spec.js b/src/embedding/router.spec.js new file mode 100644 index 0000000000..f0986b91d9 --- /dev/null +++ b/src/embedding/router.spec.js @@ -0,0 +1,37 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, jest, test } from '@jest/globals' +import { createBoardRouter } from './router.js' + +jest.mock('@vue/devtools-api', () => ({ setupDevtoolsPlugin: jest.fn() })) + +test('memory router resolves board and card routes', async () => { + const navigate = jest.fn() + const router = createBoardRouter(42, navigate) + await router.push({ name: 'board', params: { id: 42 } }) + await router.push({ name: 'card', params: { id: 42, cardId: 7, tabId: 'comments' } }) + expect(router.currentRoute.value.params).toEqual({ id: '42', cardId: '7', tabId: 'comments' }) + expect(navigate).not.toHaveBeenCalled() +}) + +test('cross-board navigation is delegated to the host, not followed', async () => { + const navigate = jest.fn() + const router = createBoardRouter(42, navigate) + await router.push({ name: 'board', params: { id: 42 } }) + await router.push({ name: 'card', params: { id: 43, cardId: 8 } }) + expect(router.currentRoute.value.name).toBe('board') + expect(router.currentRoute.value.params.id).toBe('42') + expect(navigate).toHaveBeenCalledWith({ boardId: 43, cardId: 8, tabId: null }) +}) + +test('routers are independent', async () => { + const first = createBoardRouter(42, jest.fn()) + const second = createBoardRouter(43, jest.fn()) + await first.push({ name: 'board', params: { id: 42 } }) + await second.push({ name: 'card', params: { id: 43, cardId: 8 } }) + expect(first.currentRoute.value.name).toBe('board') + expect(second.currentRoute.value.name).toBe('card') +}) diff --git a/src/embedding/storeIsolation.spec.js b/src/embedding/storeIsolation.spec.js new file mode 100644 index 0000000000..8389372cd2 --- /dev/null +++ b/src/embedding/storeIsolation.spec.js @@ -0,0 +1,130 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { beforeEach, expect, jest, test } from '@jest/globals' +import { createPinia } from 'pinia' +import { BoardApi } from '../services/BoardApi.js' +import { CardApi } from '../services/CardApi.js' +import { StackApi } from '../services/StackApi.js' +import { useBoardStore } from '../stores/board.js' +import { useCardStore } from '../stores/card.js' +import { useStackStore } from '../stores/stack.js' +import { useTrashbinStore } from '../stores/trashbin.js' + +jest.mock('@vue/devtools-api', () => ({ setupDevtoolsPlugin: jest.fn() })) +jest.mock('@nextcloud/initial-state', () => ({ loadState: (_, key) => key === 'initialBoards' ? [] : {} }), { virtual: true }) +jest.mock('@nextcloud/axios', () => ({}), { virtual: true }) +jest.mock('@nextcloud/router', () => ({ generateUrl: jest.fn() }), { virtual: true }) +jest.mock('../logger.js', () => ({ error: jest.fn() })) +jest.mock('moment', () => jest.fn()) +jest.mock('../services/BoardApi.js', () => ({ BoardApi: jest.fn() })) +jest.mock('../services/StackApi.js', () => ({ StackApi: jest.fn() })) +jest.mock('../services/CardApi.js', () => ({ CardApi: jest.fn() })) + +const labelApi = jest.fn() +const deleteApi = jest.fn() +const restoreApi = jest.fn() +const restoreCardApi = jest.fn() +const loadArchivedApi = jest.fn() + +beforeEach(() => { + BoardApi.mock.instances[0].createLabel = labelApi + for (const instance of StackApi.mock.instances) { + instance.deleteStack = deleteApi + instance.updateStack = restoreApi + instance.loadArchivedStacks = loadArchivedApi + } + for (const instance of CardApi.mock.instances) { + instance.updateCard = restoreCardApi + } +}) + +function deferred() { + let resolve + const promise = new Promise((done) => { + resolve = done + }) + return { promise, resolve } +} + +test('creating a label keeps the original card store after another instance activates', async () => { + const first = createPinia() + const second = createPinia() + const board = useBoardStore(first) + board.currentBoard = { id: 42, labels: [] } + const cards = useCardStore(first) + cards.addLabel = jest.fn().mockResolvedValue() + const pending = deferred() + labelApi.mockReturnValue(pending.promise) + const operation = board.addLabelToCurrentBoardAndCard({ newLabel: {}, card: { labels: [] } }) + const otherCards = useCardStore(second) + otherCards.addLabel = jest.fn() + pending.resolve({ id: 7 }) + await operation + expect(cards.addLabel).toHaveBeenCalledTimes(1) + expect(otherCards.addLabel).not.toHaveBeenCalled() +}) + +test('deleting a list keeps the original trash store after another instance activates', async () => { + const first = createPinia() + const stacks = useStackStore(first) + const trash = useTrashbinStore(first) + trash.moveStackToTrash = jest.fn() + const pending = deferred() + deleteApi.mockReturnValue(pending.promise) + stacks.deleteStack({ id: 1, boardId: 42 }) + const otherTrash = useTrashbinStore(createPinia()) + otherTrash.moveStackToTrash = jest.fn() + pending.resolve({ id: 1, boardId: 42 }) + await pending.promise + expect(trash.moveStackToTrash).toHaveBeenCalledTimes(1) + expect(otherTrash.moveStackToTrash).not.toHaveBeenCalled() +}) + +test('restoring a list keeps the original stack store after another instance activates', async () => { + const first = createPinia() + const trash = useTrashbinStore(first) + const stacks = useStackStore(first) + stacks.addStack = jest.fn() + const pending = deferred() + restoreApi.mockReturnValue(pending.promise) + trash.stackUndoDelete({ id: 1, boardId: 42 }) + const otherStacks = useStackStore(createPinia()) + otherStacks.addStack = jest.fn() + pending.resolve({ id: 1, boardId: 42 }) + await pending.promise + expect(stacks.addStack).toHaveBeenCalledTimes(1) + expect(otherStacks.addStack).not.toHaveBeenCalled() +}) + +test('restoring a card keeps the original card store after another instance activates', async () => { + const first = createPinia() + const trash = useTrashbinStore(first) + const cards = useCardStore(first) + const card = { id: 7, boardId: 42, deletedAt: 1 } + trash.setDeletedCards([card]) + const pending = deferred() + restoreCardApi.mockReturnValue(pending.promise) + trash.cardUndoDelete(card) + const otherCards = useCardStore(createPinia()) + pending.resolve({ ...card, deletedAt: 0 }) + await pending.promise + expect(cards.cardById(7)).toMatchObject({ deletedAt: 0 }) + expect(trash.deletedCards).toEqual([]) + expect(otherCards.cards).toEqual([]) +}) + +test('legacy archived stack loading still replaces the current card list', async () => { + const pinia = createPinia() + const cards = useCardStore(pinia) + cards.setCards([{ id: 7, stackId: 1, archived: false }]) + loadArchivedApi.mockResolvedValue([{ + id: 1, + boardId: 42, + cards: [{ id: 8, stackId: 1, archived: true }], + }]) + await useStackStore(pinia).loadArchivedStacks(42) + expect(cards.cards.map((card) => card.id)).toEqual([8]) +}) diff --git a/src/init-board-component.js b/src/init-board-component.js new file mode 100644 index 0000000000..ae25266d3e --- /dev/null +++ b/src/init-board-component.js @@ -0,0 +1,13 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { registerBoardElement } from './embedding/BoardElement.js' + +import './shared-init.js' + +registerBoardElement(async (...args) => { + const { mountBoard } = await import('./embedding/mountBoard.js') + return mountBoard(...args) +}) diff --git a/src/sessions.js b/src/sessions.js index 6b63c7dad4..21c4f321bb 100644 --- a/src/sessions.js +++ b/src/sessions.js @@ -5,6 +5,7 @@ import axios from '@nextcloud/axios' import { listen } from '@nextcloud/notify_push' +import { showError } from './helpers/errors.js' import logger from './logger.js' import { sessionApi } from './services/SessionApi.js' import { useBoardStore } from './stores/board.js' @@ -14,8 +15,6 @@ const SESSION_INTERVAL = 90 // in seconds let hasPush = false -let syncRunning = false - let store = null /** @@ -80,9 +79,19 @@ export function isNotifyPushEnabled() { /** * * @param boardId + * @param options + * @param options.pushEnabled + * @param options.boardStore + * @param options.globalHeader + * @param options.onError */ -export function createSession(boardId) { - if (!boardId || !isNotifyPushEnabled()) { +export function createSession(boardId, { + pushEnabled = isNotifyPushEnabled(), + boardStore = store, + globalHeader = true, + onError = showError, +} = {}) { + if (!boardId || !pushEnabled) { // return a dummy object return { async close() {}, @@ -93,16 +102,39 @@ export function createSession(boardId) { // the component doesn't need to bother about the asynchronousness let tokenPromise let token + let closed = false + let syncRunning = false + let generation = 0 + const clearHeader = () => { + if (globalHeader && axios.defaults.headers['x-nc-deck-session'] === token) { + delete axios.defaults.headers['x-nc-deck-session'] + } + } + const report = (error) => { + logger.error('Failed to create deck session', { error }) + onError(error) + } const create = () => { - tokenPromise = sessionApi.createSession(boardId).then((res) => res.token) - tokenPromise.then((t) => { + const currentGeneration = ++generation + tokenPromise = sessionApi.createSession(boardId).then(async ({ token: t }) => { + if (closed || currentGeneration !== generation) { + await sessionApi.closeSession(boardId, t) + return null + } token = t - axios.defaults.headers['x-nc-deck-session'] = t + if (globalHeader) { + axios.defaults.headers['x-nc-deck-session'] = t + } + return t }) + tokenPromise.catch(report) } create() const ensureSession = async () => { + if (closed) { + return + } if (!tokenPromise) { create() return @@ -114,14 +146,21 @@ export function createSession(boardId) { try { syncRunning = true - await sessionApi.syncSession(boardId, await tokenPromise) + const currentToken = await tokenPromise + if (!closed && currentToken) { + await sessionApi.syncSession(boardId, currentToken) + } } catch (err) { + if (closed) { + return + } if (err.response?.status === 404) { // session probably expired, let's // create a fresh session create() } else { logger.error('Failed to sync deck session', { error: err }) + onError(err) } } finally { syncRunning = false @@ -133,11 +172,14 @@ export function createSession(boardId) { // close session when tab gets hidden/inactive const visibilitychangeListener = () => { - if (document.visibilityState === 'hidden' && token) { - sessionApi.closeSessionViaBeacon(boardId, token) + if (document.visibilityState === 'hidden') { + generation++ + if (token) { + sessionApi.closeSessionViaBeacon(boardId, token) + } + clearHeader() tokenPromise = null token = null - delete axios.defaults.headers['x-nc-deck-session'] // stop session refresh interval clearInterval(interval) @@ -147,7 +189,12 @@ export function createSession(boardId) { // we must assume that the websocket connection was // paused and we have missed updates in the meantime. - store.refreshBoard(store.currentBoard?.id) + if (boardStore?.currentBoard) { + boardStore.refreshBoard(boardStore.currentBoard.id).catch((error) => { + logger.error('Failed to refresh deck session board', { error }) + onError(error) + }) + } // restart session refresh interval clearInterval(interval) @@ -158,13 +205,18 @@ export function createSession(boardId) { return { async close() { + if (closed) { + return + } + closed = true clearInterval(interval) document.removeEventListener('visibilitychange', visibilitychangeListener) + await tokenPromise if (token) { + clearHeader() await sessionApi.closeSession(boardId, token) tokenPromise = null token = null - delete axios.defaults.headers['x-nc-deck-session'] } }, } diff --git a/src/sessions.spec.js b/src/sessions.spec.js new file mode 100644 index 0000000000..c6d6e45230 --- /dev/null +++ b/src/sessions.spec.js @@ -0,0 +1,123 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { afterEach, beforeEach, expect, jest, test } from '@jest/globals' +import axios from '@nextcloud/axios' +import { sessionApi } from './services/SessionApi.js' +import { createSession, initSessions } from './sessions.js' + +jest.mock('@nextcloud/axios', () => ({ defaults: { headers: {} } }), { virtual: true }) +jest.mock('@nextcloud/notify_push', () => ({ listen: jest.fn().mockReturnValue(true) }), { virtual: true }) +jest.mock('./logger.js', () => ({ error: jest.fn() })) +jest.mock('./helpers/errors.js', () => ({ showError: jest.fn() })) +jest.mock('./stores/board.js', () => ({ useBoardStore: () => ({ currentBoard: { id: 42 }, refreshBoard: jest.fn().mockResolvedValue() }) })) +jest.mock('./stores/stack.js', () => ({ useStackStore: () => ({ loadStacks: jest.fn() }) })) +jest.mock('./services/SessionApi.js', () => ({ + sessionApi: { + createSession: jest.fn(), + syncSession: jest.fn().mockResolvedValue(), + closeSession: jest.fn().mockResolvedValue(), + closeSessionViaBeacon: jest.fn(), + }, +})) + +beforeEach(() => { + jest.useFakeTimers() + jest.clearAllMocks() + axios.defaults.headers = {} + sessionApi.createSession.mockResolvedValue({ token: 'session-token' }) + sessionApi.syncSession.mockResolvedValue() + // Session policy only needs visibility events, not a browser DOM. + globalThis.document = Object.assign(new EventTarget(), { visibilityState: 'visible' }) +}) +afterEach(() => { + jest.useRealTimers() + delete globalThis.document +}) + +test('legacy sessions still set and remove the global session header', async () => { + initSessions() + const session = createSession(42) + await Promise.resolve() + expect(axios.defaults.headers['x-nc-deck-session']).toBe('session-token') + await session.close() + expect(sessionApi.closeSession).toHaveBeenCalledWith(42, 'session-token') + expect(axios.defaults.headers['x-nc-deck-session']).toBeUndefined() + expect(jest.getTimerCount()).toBe(0) +}) + +test('embedded sessions never alter the existing global header', async () => { + axios.defaults.headers['x-nc-deck-session'] = 'host-session' + const session = createSession(42, { pushEnabled: true, globalHeader: false }) + await Promise.resolve() + await session.close() + expect(axios.defaults.headers['x-nc-deck-session']).toBe('host-session') + expect(jest.getTimerCount()).toBe(0) +}) + +test('closing before creation resolves closes the late session', async () => { + let resolve + sessionApi.createSession.mockReturnValue(new Promise((done) => { + resolve = done + })) + const session = createSession(42, { pushEnabled: true, globalHeader: false }) + const closed = session.close() + resolve({ token: 'late-session' }) + await closed + expect(sessionApi.closeSession).toHaveBeenCalledTimes(1) + expect(sessionApi.closeSession).toHaveBeenCalledWith(42, 'late-session') + expect(jest.getTimerCount()).toBe(0) +}) + +test('visibility refresh uses the supplied board store', async () => { + const board = { currentBoard: { id: 43 }, refreshBoard: jest.fn().mockResolvedValue() } + const session = createSession(43, { pushEnabled: true, globalHeader: false, boardStore: board }) + await Promise.resolve() + document.dispatchEvent(new Event('visibilitychange')) + expect(board.refreshBoard).toHaveBeenCalledWith(43) + await session.close() + document.dispatchEvent(new Event('visibilitychange')) + expect(board.refreshBoard).toHaveBeenCalledTimes(1) +}) + +test('sessions without push perform no API calls', async () => { + const session = createSession(42, { pushEnabled: false }) + await session.close() + expect(sessionApi.createSession).not.toHaveBeenCalled() + expect(jest.getTimerCount()).toBe(0) +}) + +test('hiding the page while creation is pending closes the late session', async () => { + let resolve + sessionApi.createSession.mockReturnValue(new Promise((done) => { + resolve = done + })) + const session = createSession(42, { pushEnabled: true, globalHeader: false }) + Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'hidden' }) + document.dispatchEvent(new Event('visibilitychange')) + resolve({ token: 'hidden-session' }) + await Promise.resolve() + await Promise.resolve() + await session.close() + expect(sessionApi.closeSession).toHaveBeenCalledWith(42, 'hidden-session') + expect(jest.getTimerCount()).toBe(0) +}) + +test('a late failed sync cannot reopen a disposed session', async () => { + let reject + sessionApi.syncSession.mockReturnValue(new Promise((_, fail) => { + reject = fail + })) + const session = createSession(42, { pushEnabled: true, globalHeader: false }) + await Promise.resolve() + await jest.advanceTimersByTimeAsync(90000) + expect(sessionApi.syncSession).toHaveBeenCalledTimes(1) + await session.close() + reject({ response: { status: 404 } }) + await Promise.resolve() + await Promise.resolve() + expect(sessionApi.createSession).toHaveBeenCalledTimes(1) + expect(jest.getTimerCount()).toBe(0) +}) diff --git a/src/stores/board.js b/src/stores/board.js index d0f2d2b25e..fde6822864 100644 --- a/src/stores/board.js +++ b/src/stores/board.js @@ -267,11 +267,12 @@ export const useBoardStore = defineStore('board', { this.currentBoard?.labels?.push(created) }, async addLabelToCurrentBoardAndCard({ newLabel, card }) { + const cardStore = useCardStore() newLabel.boardId = this.currentBoard.id const label = await apiClient.createLabel(newLabel) card.labels.push(label) this.currentBoard?.labels?.push(label) - await useCardStore().addLabel({ + await cardStore.addLabel({ card, labelId: label.id, }) diff --git a/src/stores/stack.js b/src/stores/stack.js index 918c86c8cf..882045dc06 100644 --- a/src/stores/stack.js +++ b/src/stores/stack.js @@ -69,7 +69,7 @@ export const useStackStore = defineStore('stack', { } cardStore.setCards(cards) }, - async loadArchivedStacks(boardId) { + async loadArchivedStacks(boardId, { preserveExistingCards = false } = {}) { const cardStore = useCardStore() const archivedStacks = await apiClient.loadArchivedStacks(boardId) const cards = [] @@ -83,7 +83,13 @@ export const useStackStore = defineStore('stack', { this.addStack(stack) } } - cardStore.setCards(cards) + if (preserveExistingCards) { + for (const card of cards) { + cardStore.addCardToStore(card) + } + } else { + cardStore.setCards(cards) + } }, createStack(stack) { stack.boardId = useBoardStore().currentBoard.id @@ -93,13 +99,14 @@ export const useStackStore = defineStore('stack', { }) }, deleteStack(stack) { + const trashbin = useTrashbinStore() apiClient.deleteStack(stack.id, stack.boardId) .then((stack) => { const existingIndex = this.stacks.findIndex((_stack) => _stack.id === stack.id) if (existingIndex !== -1) { this.stacks.splice(existingIndex, 1) } - useTrashbinStore().moveStackToTrash(stack) + trashbin.moveStackToTrash(stack) }) }, updateStack(stack) { diff --git a/src/stores/trashbin.js b/src/stores/trashbin.js index e75a3486be..138b5a96d5 100644 --- a/src/stores/trashbin.js +++ b/src/stores/trashbin.js @@ -57,15 +57,17 @@ export const useTrashbinStore = defineStore('trashbin', { }) }, stackUndoDelete(stack) { + const stackStore = useStackStore() stackApi.updateStack(stack).then((restoredStack) => { - useStackStore().addStack(restoredStack) + stackStore.addStack(restoredStack) this.removeStackFromTrash(restoredStack) }) }, cardUndoDelete(card) { + const cardStore = useCardStore() cardApi.updateCard(card, card.boardId).then((restoredCard) => { this.removeCardFromTrash(restoredCard) - useCardStore().addCardToStore(restoredCard) + cardStore.addCardToStore(restoredCard) }) }, }, diff --git a/src/views/BoardReferenceWidget.spec.js b/src/views/BoardReferenceWidget.spec.js new file mode 100644 index 0000000000..47b4135cc5 --- /dev/null +++ b/src/views/BoardReferenceWidget.spec.js @@ -0,0 +1,37 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { beforeEach, expect, jest, test } from '@jest/globals' +import Board from '../components/board/Board.vue' +import BoardReferenceWidget from './BoardReferenceWidget.vue' +import { BoardApi } from '../services/BoardApi.js' +import { useSettingsStore } from '../stores/settings.js' + +jest.mock('../components/board/Board.vue', () => ({})) +jest.mock('../components/icons/DeckIcon.vue', () => ({})) +jest.mock('../services/BoardApi.js', () => ({ BoardApi: jest.fn() })) +jest.mock('../stores/settings.js', () => ({ useSettingsStore: jest.fn() })) +jest.mock('@nextcloud/vue', () => ({}), { virtual: true }) +jest.mock('@nextcloud/moment', () => ({}), { virtual: true }) +jest.mock('@nextcloud/router', () => ({ generateUrl: () => '/apps/deck/#/board/42' }), { virtual: true }) + +beforeEach(() => { + useSettingsStore.mockReturnValue({ setFullApp: jest.fn() }) +}) + +test('reference widget keeps the existing board component and API provider', () => { + expect(BoardReferenceWidget.components.Board).toBe(Board) + expect(BoardReferenceWidget.provide().boardApi).toBeInstanceOf(BoardApi) +}) + +test('reference widgets disable full-app behavior', () => { + BoardReferenceWidget.created() + expect(useSettingsStore().setFullApp).toHaveBeenCalledWith(false) +}) + +test('non-interactive reference links to the existing board route', () => { + expect(BoardReferenceWidget.computed.boardLink.call({ board: { id: 42 } })) + .toBe('/apps/deck/#/board/42') +}) diff --git a/tests/psalm-baseline.xml b/tests/psalm-baseline.xml index 8d7ec75f7d..c962c6e39f 100644 --- a/tests/psalm-baseline.xml +++ b/tests/psalm-baseline.xml @@ -5,11 +5,6 @@ - - - - - diff --git a/tests/stub.phpstub b/tests/stub.phpstub index 73673f72e5..908321c8a6 100644 --- a/tests/stub.phpstub +++ b/tests/stub.phpstub @@ -730,6 +730,10 @@ namespace OCA\NotifyPush\Queue { } } +namespace OCA\Files\Event { + class LoadSidebar extends \OCP\EventDispatcher\Event {} +} + namespace OCA\Text\Event { class LoadEditor extends \OCP\EventDispatcher\Event {} } diff --git a/tests/unit/Listeners/LoadBoardWebComponentListenerTest.php b/tests/unit/Listeners/LoadBoardWebComponentListenerTest.php new file mode 100644 index 0000000000..c57647e9db --- /dev/null +++ b/tests/unit/Listeners/LoadBoardWebComponentListenerTest.php @@ -0,0 +1,55 @@ +createMock(IInitialState::class); + $dispatcher = $this->createMock(IEventDispatcher::class); + $state->expects($this->never())->method('provideInitialState'); + $dispatcher->expects($this->never())->method('dispatchTyped'); + + (new LoadBoardWebComponentListener($state, $dispatcher))->handle(new Event()); + } + + public function testLoadsUploadStateAndIntegrations(): void { + $state = $this->createMock(IInitialState::class); + $dispatcher = $this->createMock(IEventDispatcher::class); + $state->expects($this->once())->method('provideInitialState') + ->with('maxUploadSize', $this->isType('int')); + $events = []; + $dispatcher->method('dispatchTyped')->willReturnCallback( + static function (Event $event) use (&$events): void { + $events[] = get_class($event); + } + ); + + (new LoadBoardWebComponentListener($state, $dispatcher))->handle(new LoadBoardWebComponentEvent()); + + $expected = [LoadSidebar::class, LoadAdditionalScriptsEvent::class]; + if (class_exists(LoadEditor::class)) { + $expected[] = LoadEditor::class; + } + if (class_exists(LoadViewer::class)) { + $expected[] = LoadViewer::class; + } + $this->assertSame($expected, $events); + } +} diff --git a/webpack.js b/webpack.js index 78b144c64c..231e7da0b9 100644 --- a/webpack.js +++ b/webpack.js @@ -12,6 +12,7 @@ webpackConfig.entry = { calendar: path.join(__dirname, 'src', 'init-calendar.js'), talk: path.join(__dirname, 'src', 'init-talk.js'), reference: path.join(__dirname, 'src', 'init-reference.js'), + boardComponent: path.join(__dirname, 'src', 'init-board-component.js'), } if (isDevServer) {