From e47056267b32ed719cd2a10e91b028cc95083158 Mon Sep 17 00:00:00 2001 From: Stefan Lender Date: Thu, 15 Oct 2026 00:01:37 +0200 Subject: [PATCH] feat(deck): add embeddable Vue board web component Expose nextcloud-deck-board through an opt-in loading event so other Nextcloud apps can reuse the existing board and card detail components. Isolate embedded stores, routing and sessions without changing the host URL or global session headers. Preserve active cards when opening archived card details and keep undo actions bound to their board instance. Co-authored-by: Copilot GPT-6.1 Sol Signed-off-by: Stefan Lender --- README.md | 86 ++++ cypress/e2e/embeddedBoard.js | 385 ++++++++++++++++++ lib/AppInfo/Application.php | 3 + lib/Event/LoadBoardWebComponentEvent.php | 15 + .../LoadBoardWebComponentListener.php | 48 +++ package.json | 5 +- src/CardMoveDialog.vue | 12 +- src/components/Controls.vue | 13 +- src/components/board/Board.spec.js | 103 +++++ src/components/board/Board.vue | 41 +- .../cards/NcActionsCardMenuEntries.spec.js | 68 ++++ .../cards/NcActionsCardMenuEntries.vue | 13 +- src/embedding/BoardElement.js | 24 ++ src/embedding/BoardElement.spec.js | 19 + src/embedding/BoardElement.vue | 192 +++++++++ src/embedding/EmbeddedBoard.vue | 114 ++++++ src/embedding/mountBoard.js | 139 +++++++ src/embedding/push.js | 36 ++ src/embedding/push.spec.js | 32 ++ src/embedding/router.js | 30 ++ src/embedding/router.spec.js | 37 ++ src/embedding/storeIsolation.spec.js | 130 ++++++ src/init-board-component.js | 13 + src/sessions.js | 78 +++- src/sessions.spec.js | 123 ++++++ src/stores/board.js | 3 +- src/stores/stack.js | 13 +- src/stores/trashbin.js | 6 +- src/views/BoardReferenceWidget.spec.js | 37 ++ tests/psalm-baseline.xml | 5 - tests/stub.phpstub | 4 + .../LoadBoardWebComponentListenerTest.php | 55 +++ webpack.js | 1 + 33 files changed, 1845 insertions(+), 38 deletions(-) create mode 100644 cypress/e2e/embeddedBoard.js create mode 100644 lib/Event/LoadBoardWebComponentEvent.php create mode 100644 lib/Listeners/LoadBoardWebComponentListener.php create mode 100644 src/components/board/Board.spec.js create mode 100644 src/components/cards/NcActionsCardMenuEntries.spec.js create mode 100644 src/embedding/BoardElement.js create mode 100644 src/embedding/BoardElement.spec.js create mode 100644 src/embedding/BoardElement.vue create mode 100644 src/embedding/EmbeddedBoard.vue create mode 100644 src/embedding/mountBoard.js create mode 100644 src/embedding/push.js create mode 100644 src/embedding/push.spec.js create mode 100644 src/embedding/router.js create mode 100644 src/embedding/router.spec.js create mode 100644 src/embedding/storeIsolation.spec.js create mode 100644 src/init-board-component.js create mode 100644 src/sessions.spec.js create mode 100644 src/views/BoardReferenceWidget.spec.js create mode 100644 tests/unit/Listeners/LoadBoardWebComponentListenerTest.php 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) {