diff --git a/eslint.config.js b/eslint.config.js
index bc2330ac4..21fd6d440 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -11,4 +11,13 @@ export default [
// Ported team-page code, kept verbatim — not linted while it's modernized.
ignores: ['src/teams/team-page/**'],
},
+ {
+ files: ['src/teams/views/TeamDeckView.vue'],
+ // Native attributes and events must match Deck's custom-element contract.
+ rules: {
+ 'vue/no-undef-components': ['warn', { ignorePatterns: ['nextcloud-deck-board'] }],
+ 'vue/attribute-hyphenation': 'off',
+ 'vue/v-on-event-hyphenation': 'off',
+ },
+ },
]
diff --git a/lib/Controller/PageController.php b/lib/Controller/PageController.php
index cc12d8b49..ee0b66c80 100644
--- a/lib/Controller/PageController.php
+++ b/lib/Controller/PageController.php
@@ -12,7 +12,9 @@
use OCA\Circles\AppInfo\Application;
use OCA\Circles\Service\ConfigService;
use OCA\Circles\Service\TeamFolderPolicy;
+use OCA\Deck\Event\LoadBoardWebComponentEvent;
use OCA\Text\Event\LoadEditor;
+use OCP\App\IAppManager;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http\Attribute\FrontpageRoute;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
@@ -36,6 +38,7 @@ public function __construct(
private ITeamManager $teamManager,
private TeamFolderPolicy $teamFolderPolicy,
private IEventDispatcher $eventDispatcher,
+ private IAppManager $appManager,
) {
parent::__construct(Application::APP_ID, $request);
}
@@ -60,6 +63,13 @@ public function index(): TemplateResponse|NotFoundResponse {
Util::addScript(Application::APP_ID, 'teams-main');
Util::addStyle(Application::APP_ID, 'teams-main');
+ $deckEmbeddingAvailable = $this->appManager->isEnabledForUser('deck')
+ && class_exists(LoadBoardWebComponentEvent::class);
+ $this->initialState->provideInitialState('deckEmbeddingAvailable', $deckEmbeddingAvailable);
+ if ($deckEmbeddingAvailable) {
+ $this->eventDispatcher->dispatchTyped(new LoadBoardWebComponentEvent());
+ }
+
// Load the Text editor so team pages can be edited inline on their
// tabs. The class only resolves while the Text app is enabled.
if (class_exists(LoadEditor::class)) {
diff --git a/psalm.xml b/psalm.xml
index 1c9425bc1..60194ed07 100644
--- a/psalm.xml
+++ b/psalm.xml
@@ -58,6 +58,7 @@
+
diff --git a/src/teams/components/TeamNavigation.spec.ts b/src/teams/components/TeamNavigation.spec.ts
new file mode 100644
index 000000000..4486f6ba9
--- /dev/null
+++ b/src/teams/components/TeamNavigation.spec.ts
@@ -0,0 +1,105 @@
+/*!
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { shallowMount } from '@vue/test-utils'
+import { createPinia } from 'pinia'
+import { describe, expect, it, vi } from 'vitest'
+import { createMemoryHistory, createRouter } from 'vue-router'
+import TeamNavigation from './TeamNavigation.vue'
+import { useTeamResourcesStore } from '../resourcesStore.ts'
+
+vi.mock('@nextcloud/dialogs', () => ({ showConfirmation: vi.fn(), showError: vi.fn(), showSuccess: vi.fn() }))
+vi.mock(import('@nextcloud/l10n'), async (importOriginal) => ({
+ ...await importOriginal(),
+ t: (_: string, text: string) => text,
+}))
+vi.mock('vuex', () => ({ useStore: () => ({ getters: { getCircle: () => null } }) }))
+vi.mock('../store.ts', () => ({ useTeamsStore: () => ({ getTeam: () => null }) }))
+vi.mock('../composables/useTeamActions.ts', async () => {
+ const { ref } = await import('vue')
+ return { canCreateTeamFolder: () => false, useTeamActions: () => ({ canManage: ref(false), isTeamAdmin: ref(false) }) }
+})
+vi.mock('./TeamHeader.vue', () => ({ default: { render: () => null } }))
+vi.mock('./TeamSettingsDialog.vue', () => ({ default: { render: () => null } }))
+vi.mock('../api.ts', () => ({
+ createCollective: vi.fn(),
+ createDeckBoard: vi.fn(),
+ createTeamPage: vi.fn(),
+ deleteTeamPage: vi.fn(),
+ renameTeamPage: vi.fn(),
+ getTeamFolder: vi.fn(),
+ upgradeTeamFolder: vi.fn(),
+ fetchTeamPages: vi.fn(),
+ fetchTeamResources: vi.fn(),
+ fetchTeamDeckBoards: vi.fn(),
+ fetchTabOrder: vi.fn(),
+ saveTabOrder: vi.fn(),
+}))
+
+async function mountNavigation(path: string) {
+ const pinia = createPinia()
+ const state = useTeamResourcesStore(pinia).slot('team-1')
+ state.boards = [{ id: 7, title: 'Team board', url: '/apps/deck/board/7' }]
+ state.pages = [{ fileId: 5, title: 'Handbook', filePath: '/team/.pages/Handbook.md' }]
+ const router = createRouter({
+ history: createMemoryHistory(),
+ routes: [
+ { name: 'team-folder', path: '/team/:teamId/folder', component: {} },
+ { name: 'team-home', path: '/team/:teamId/shared', component: {} },
+ { name: 'team-page', path: '/team/:teamId/page/:fileId', component: {} },
+ { name: 'team-deck', path: '/team/:teamId/deck/:boardId', component: {} },
+ { name: 'team-deck-card', path: '/team/:teamId/deck/:boardId/card/:cardId', component: {} },
+ ],
+ })
+ await router.push(path)
+ return shallowMount(TeamNavigation, {
+ global: {
+ plugins: [pinia, router],
+ stubs: {
+ NcAppNavigation: { template: '' },
+ NcAppNavigationItem: {
+ name: 'NcAppNavigationItem',
+ props: ['name', 'to', 'href', 'active'],
+ template: '
',
+ },
+ },
+ },
+ })
+}
+
+describe('embedded board navigation', () => {
+ it.each(['/team/team-1/deck/7', '/team/team-1/deck/7/card/9'])('highlights the board for %s', async (path) => {
+ const wrapper = await mountNavigation(path)
+ const entries = wrapper.findAllComponents({ name: 'NcAppNavigationItem' })
+ const board = entries.find((entry) => entry.props('name') === 'Team board')!
+ expect(board.props('active')).toBe(true)
+ expect(board.props('href')).toBeUndefined()
+ expect(board.props('to')).toEqual({ name: 'team-deck', params: { teamId: 'team-1', boardId: '7' } })
+ expect(entries.find((entry) => entry.props('name') === 'Shared with the team')!.props('active')).toBe(false)
+ wrapper.unmount()
+ })
+
+ it('highlights shared resources for a personally shared board', async () => {
+ const wrapper = await mountNavigation('/team/team-1/deck/8/card/9')
+ const entries = wrapper.findAllComponents({ name: 'NcAppNavigationItem' })
+ expect(entries.find((entry) => entry.props('name') === 'Shared with the team')!.props('active')).toBe(true)
+ expect(entries.find((entry) => entry.props('name') === 'Team board')!.props('active')).toBe(false)
+ wrapper.unmount()
+ })
+
+ it('keeps the existing folder and page route links', async () => {
+ const wrapper = await mountNavigation('/team/team-1/page/5')
+ const entries = wrapper.findAllComponents({ name: 'NcAppNavigationItem' })
+ expect(entries.find((entry) => entry.props('name') === 'Handbook')!.props('to')).toEqual({
+ name: 'team-page',
+ params: { teamId: 'team-1', fileId: '5' },
+ })
+ expect(entries.find((entry) => entry.props('name') === 'Team folder')!.props('to')).toEqual({
+ name: 'team-folder',
+ params: { teamId: 'team-1' },
+ })
+ wrapper.unmount()
+ })
+})
diff --git a/src/teams/components/TeamNavigation.vue b/src/teams/components/TeamNavigation.vue
index 133f55c48..a171f2a6d 100644
--- a/src/teams/components/TeamNavigation.vue
+++ b/src/teams/components/TeamNavigation.vue
@@ -31,6 +31,7 @@ import { createCollective, createDeckBoard, createTeamPage, deleteTeamPage, rena
import collectivesIcon from '../assets/collectives.svg?raw'
import deckIcon from '../assets/deck.svg?raw'
import { canCreateTeamFolder, useTeamActions } from '../composables/useTeamActions.ts'
+import { isTeamDeckRoute, teamDeckRoute } from '../deck.ts'
import { useTeamResourcesStore } from '../resourcesStore.ts'
import { useTeamsStore } from '../store.ts'
@@ -43,6 +44,7 @@ interface NavigationEntry {
iconSvg?: string
to?: RouteLocationRaw
href?: string
+ active?: boolean
/** Set for page entries, which carry per-entry actions. */
page?: TeamPage
}
@@ -124,7 +126,8 @@ const entries = computed(() => {
id: `board-${board.id}`,
label: board.title,
iconSvg: deckIcon,
- href: board.url,
+ to: teamDeckRoute(teamId.value, board.id),
+ active: isTeamDeckRoute(route.name) && route.params.boardId === String(board.id),
})
}
for (const page of pages.value) {
@@ -142,6 +145,7 @@ const entries = computed(() => {
label: t('circles', 'Shared with the team'),
icon: route.name === 'team-home' ? mdiShareVariant : mdiShareVariantOutline,
to: { name: 'team-home', params: { teamId: teamId.value } },
+ active: isTeamDeckRoute(route.name) && !boards.value.some((board) => String(board.id) === route.params.boardId),
})
return items
})
@@ -538,6 +542,7 @@ async function onEntryDragEnd(): Promise {
:name="entry.label"
:to="entry.to"
:href="entry.href"
+ :active="entry.active"
:editable="Boolean(entry.page) && isTeamAdmin"
:editLabel="t('circles', 'Rename page')"
:editPlaceholder="t('circles', 'Page name')"
diff --git a/src/teams/deck.spec.ts b/src/teams/deck.spec.ts
new file mode 100644
index 000000000..8281c4737
--- /dev/null
+++ b/src/teams/deck.spec.ts
@@ -0,0 +1,45 @@
+/*!
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { describe, expect, it } from 'vitest'
+import { isTeamDeckRoute, positiveDeckId, teamDeckRoute, teamViewKey } from './deck.ts'
+
+describe('embedded Deck routes', () => {
+ it('builds board and card routes with canonical string identifiers', () => {
+ expect(teamDeckRoute('team-1', 7)).toEqual({
+ name: 'team-deck',
+ params: { teamId: 'team-1', boardId: '7' },
+ })
+ expect(teamDeckRoute('team-1', 7, 9)).toEqual({
+ name: 'team-deck-card',
+ params: { teamId: 'team-1', boardId: '7', cardId: '9' },
+ })
+ })
+
+ it('keeps the same view key for a board and its cards', () => {
+ const board = { name: 'team-deck', params: { teamId: 'team-1', boardId: '7' }, path: '/team/team-1/deck/7' }
+ const card = { name: 'team-deck-card', params: { ...board.params, cardId: '9' }, path: `${board.path}/card/9` }
+ expect(teamViewKey(board)).toBe(teamViewKey(card))
+ expect(teamViewKey({ ...board, params: { ...board.params, boardId: '8' } })).not.toBe(teamViewKey(board))
+ expect(teamViewKey({ ...board, params: { ...board.params, teamId: 'team-2' } })).not.toBe(teamViewKey(board))
+ })
+
+ it('preserves path-based remounting for existing team pages', () => {
+ expect(teamViewKey({ name: 'team-page', params: {}, path: '/team/team-1/page/5' })).toBe('/team/team-1/page/5')
+ expect(isTeamDeckRoute('team-folder')).toBe(false)
+ expect(isTeamDeckRoute('team-deck-card')).toBe(true)
+ })
+})
+
+describe('Deck identifier validation', () => {
+ it.each([null, undefined, '', '0', 0, -1, '1.5', '1e2', ' 7', NaN, Infinity, Number.MAX_SAFE_INTEGER + 1])('rejects %s', (value) => {
+ expect(positiveDeckId(value)).toBeNull()
+ })
+
+ it('accepts numeric and string positive safe integers', () => {
+ expect(positiveDeckId(7)).toBe(7)
+ expect(positiveDeckId('7')).toBe(7)
+ })
+})
diff --git a/src/teams/deck.ts b/src/teams/deck.ts
new file mode 100644
index 000000000..7b14a770b
--- /dev/null
+++ b/src/teams/deck.ts
@@ -0,0 +1,57 @@
+/*!
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { RouteLocationNormalizedLoaded, RouteLocationRaw } from 'vue-router'
+
+/**
+ * Keep Deck navigation within the selected team.
+ *
+ * @param teamId - Active team identifier.
+ * @param boardId - Deck board identifier.
+ * @param cardId - Optional card to open.
+ */
+export function teamDeckRoute(teamId: string, boardId: string | number, cardId: number | null = null): RouteLocationRaw {
+ return {
+ name: cardId === null ? 'team-deck' : 'team-deck-card',
+ params: {
+ teamId,
+ boardId: String(boardId),
+ ...(cardId === null ? {} : { cardId: String(cardId) }),
+ },
+ }
+}
+
+/**
+ * Identify both board and card routes.
+ *
+ * @param name - Current route name.
+ */
+export function isTeamDeckRoute(name: RouteLocationNormalizedLoaded['name']): boolean {
+ return name === 'team-deck' || name === 'team-deck-card'
+}
+
+/**
+ * Keep the mounted board across card changes, but not team or board changes.
+ *
+ * @param route - Current host route.
+ */
+export function teamViewKey(route: Pick): string {
+ return isTeamDeckRoute(route.name)
+ ? `deck:${route.params.teamId}:${route.params.boardId}`
+ : route.path
+}
+
+/**
+ * Validate identifiers from route parameters and custom-element events.
+ *
+ * @param value - Untrusted identifier.
+ */
+export function positiveDeckId(value: unknown): number | null {
+ if (typeof value !== 'number' && (typeof value !== 'string' || !/^[1-9]\d*$/.test(value))) {
+ return null
+ }
+ const id = Number(value)
+ return Number.isSafeInteger(id) && id > 0 ? id : null
+}
diff --git a/src/teams/firstTab.spec.ts b/src/teams/firstTab.spec.ts
index 7cbabcf57..877150730 100644
--- a/src/teams/firstTab.spec.ts
+++ b/src/teams/firstTab.spec.ts
@@ -48,10 +48,20 @@ describe('firstTabRoute', () => {
pages: [page(7, 'Handbook')],
tabOrder: ['page-7', 'team-folder', 'home'],
})
+
expect(firstTabRoute(TEAM, state))
.toEqual({ name: 'team-page', params: { teamId: TEAM, fileId: '7' } })
})
+ it('resolves a board as the first entry without changing its saved id', () => {
+ const state = resources({
+ boards: [{ id: 3, title: 'Tasks', url: '/apps/deck/board/3' }],
+ tabOrder: ['board-3', 'team-folder', 'home'],
+ })
+ expect(firstTabRoute(TEAM, state))
+ .toEqual({ name: 'team-deck', params: { teamId: TEAM, boardId: '3' } })
+ })
+
it('skips external entries at the front of the order', () => {
const state = resources({ tabOrder: ['collective', 'board-3', 'home', 'team-folder'] })
expect(firstTabRoute(TEAM, state))
@@ -75,9 +85,10 @@ describe('firstTabRoute', () => {
})
describe('firstTabSettled', () => {
- it('waits for the order and the pages', () => {
+ it('waits for the order, pages and boards', () => {
expect(firstTabSettled(resources({ orderChecked: false }))).toBe(false)
expect(firstTabSettled(resources({ pagesChecked: false }))).toBe(false)
+ expect(firstTabSettled(resources({ boardsChecked: false }))).toBe(false)
expect(firstTabSettled(resources())).toBe(true)
})
diff --git a/src/teams/firstTab.ts b/src/teams/firstTab.ts
index f38595e50..c90cff2b5 100644
--- a/src/teams/firstTab.ts
+++ b/src/teams/firstTab.ts
@@ -6,24 +6,25 @@
import type { RouteLocationRaw } from 'vue-router'
import type { TeamResources } from './resourcesStore.ts'
+import { teamDeckRoute } from './deck.ts'
+
/**
* Whether enough of a team's resource state is known to resolve the first
- * navigation entry: the tab order and the pages, the only order-dependent
- * entries with in-app routes.
+ * navigation entry: the tab order, pages, and team boards.
*
* @param resources - The team's resource state
*/
export function firstTabSettled(resources: TeamResources): boolean {
const orderSettled = resources.orderChecked || resources.orderError
const pagesSettled = resources.pagesChecked || resources.pagesError || resources.folderError
- return orderSettled && pagesSettled
+ return orderSettled && pagesSettled && resources.boardsChecked
}
/**
* The route of the first entry in the team navigation, following the saved
* tab order like the sidebar does; entries the order does not know keep
- * their natural order behind the known ones. External entries (collective,
- * boards) cannot be a landing page and are skipped.
+ * their natural order behind the known ones. The external collective entry
+ * cannot be a landing page and is skipped.
*
* @param teamId - The team whose navigation is resolved
* @param resources - The team's resource state
@@ -31,6 +32,9 @@ export function firstTabSettled(resources: TeamResources): boolean {
export function firstTabRoute(teamId: string, resources: TeamResources): RouteLocationRaw {
const targets = new Map()
targets.set('team-folder', { name: 'team-folder', params: { teamId } })
+ for (const board of resources.boards) {
+ targets.set(`board-${board.id}`, teamDeckRoute(teamId, board.id))
+ }
for (const page of resources.pages) {
targets.set(`page-${page.fileId}`, { name: 'team-page', params: { teamId, fileId: String(page.fileId) } })
}
diff --git a/src/teams/router.ts b/src/teams/router.ts
index 1e2a026f0..2c791313b 100644
--- a/src/teams/router.ts
+++ b/src/teams/router.ts
@@ -10,6 +10,7 @@ import { createRouter, createWebHistory } from 'vue-router'
import HomeView from './views/HomeView.vue'
import JoinInvitation from './views/JoinInvitation.vue'
import PageView from './views/PageView.vue'
+import TeamDeckView from './views/TeamDeckView.vue'
import TeamFolderView from './views/TeamFolderView.vue'
import TeamHomeView from './views/TeamHomeView.vue'
import TeamLandingView from './views/TeamLandingView.vue'
@@ -44,6 +45,18 @@ const routes: RouteRecordRaw[] = [
component: TeamLandingView,
props: true,
},
+ {
+ name: 'team-deck',
+ path: 'deck/:boardId',
+ component: TeamDeckView,
+ props: true,
+ },
+ {
+ name: 'team-deck-card',
+ path: 'deck/:boardId/card/:cardId',
+ component: TeamDeckView,
+ props: true,
+ },
{
name: 'team-folder',
path: 'folder',
diff --git a/src/teams/views/TeamDeckView.spec.ts b/src/teams/views/TeamDeckView.spec.ts
new file mode 100644
index 000000000..c66095b39
--- /dev/null
+++ b/src/teams/views/TeamDeckView.spec.ts
@@ -0,0 +1,248 @@
+/*!
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { flushPromises, shallowMount } from '@vue/test-utils'
+import { createPinia } from 'pinia'
+import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
+import { defineComponent, defineCustomElement, h } from 'vue'
+import { createMemoryHistory, createRouter, RouterView, useRoute } from 'vue-router'
+import TeamDeckView from './TeamDeckView.vue'
+import { teamViewKey } from '../deck.ts'
+import { useTeamResourcesStore } from '../resourcesStore.ts'
+
+const loadState = vi.hoisted(() => vi.fn())
+const showError = vi.hoisted(() => vi.fn())
+const logger = vi.hoisted(() => ({ error: vi.fn(), warn: vi.fn() }))
+
+vi.mock('@nextcloud/initial-state', () => ({ loadState }))
+vi.mock('@nextcloud/dialogs', () => ({ showError }))
+vi.mock('@nextcloud/l10n', () => ({ t: (_: string, text: string) => text }))
+vi.mock('../../logger.ts', () => ({ logger }))
+vi.mock('../api.ts', () => ({
+ getTeamFolder: vi.fn(),
+ upgradeTeamFolder: vi.fn(),
+ fetchTeamPages: vi.fn(),
+ fetchTeamResources: vi.fn(),
+ fetchTeamDeckBoards: vi.fn(),
+ fetchTabOrder: vi.fn(),
+ saveTabOrder: vi.fn(),
+}))
+
+const wrappers: ReturnType[] = []
+
+beforeAll(() => {
+ if (!customElements.get('nextcloud-deck-board')) {
+ customElements.define('nextcloud-deck-board', defineCustomElement(defineComponent({
+ props: { boardId: String, cardId: String },
+ setup(props) {
+ return () => h('div', { 'data-board-id': props.boardId, 'data-card-id': props.cardId })
+ },
+ }), { shadowRoot: false }))
+ }
+})
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ loadState.mockReturnValue(true)
+})
+
+afterEach(() => {
+ for (const wrapper of wrappers.splice(0)) {
+ wrapper.unmount()
+ }
+ vi.restoreAllMocks()
+ vi.useRealTimers()
+ document.body.replaceChildren()
+})
+
+async function mountView(options: { available?: boolean, loaded?: boolean, shared?: boolean, boardId?: string, cardId?: string, routed?: boolean } = {}) {
+ loadState.mockReturnValue(options.available ?? true)
+ const pinia = createPinia()
+ const state = useTeamResourcesStore(pinia).slot('team-1')
+ state.resourcesChecked = options.loaded ?? true
+ state.boardsChecked = options.loaded ?? true
+ state.resources = options.shared === false
+ ? []
+ : [7, 8].map((id) => ({
+ id,
+ label: `Board ${id}`,
+ url: `/apps/deck/board/${id}`,
+ provider: { id: 'deck', name: 'Deck' },
+ }))
+ const router = createRouter({
+ history: createMemoryHistory(),
+ routes: [
+ { name: 'team-deck', path: '/team/:teamId/deck/:boardId', component: TeamDeckView, props: true },
+ { name: 'team-deck-card', path: '/team/:teamId/deck/:boardId/card/:cardId', component: TeamDeckView, props: true },
+ ],
+ })
+ await router.push('/team/team-1/deck/7')
+ const host = defineComponent({
+ setup() {
+ const route = useRoute()
+ return () => h(RouterView, { key: teamViewKey(route) })
+ },
+ })
+ const wrapper = shallowMount(options.routed ? host : TeamDeckView, {
+ attachTo: document.body,
+ props: options.routed ? {} : { teamId: 'team-1', boardId: options.boardId ?? '7', cardId: options.cardId },
+ global: {
+ plugins: [pinia, router],
+ stubs: {
+ RouterView: false,
+ TeamDeckView: false,
+ NcEmptyContent: {
+ props: ['name', 'description'],
+ template: '{{ name }} {{ description }}
',
+ },
+ NcButton: { template: '' },
+ },
+ },
+ })
+ wrappers.push(wrapper)
+ await flushPromises()
+ return { wrapper, router, state }
+}
+
+describe('TeamDeckView', () => {
+ it('embeds only a board confirmed in the current team resources', async () => {
+ const { wrapper } = await mountView()
+ expect(wrapper.get('nextcloud-deck-board').attributes('board-id')).toBe('7')
+ expect(wrapper.get('nextcloud-deck-board').attributes('card-id')).toBeUndefined()
+ })
+
+ it('waits for the team resources before mounting Deck', async () => {
+ const { wrapper, state } = await mountView({ loaded: false })
+ expect(wrapper.find('nextcloud-deck-board').exists()).toBe(false)
+ state.resourcesChecked = true
+ await flushPromises()
+ expect(wrapper.find('nextcloud-deck-board').exists()).toBe(true)
+ })
+
+ it('does not embed a board outside the current team', async () => {
+ const { wrapper } = await mountView({ shared: false })
+ expect(wrapper.find('nextcloud-deck-board').exists()).toBe(false)
+ expect(wrapper.text()).toContain('Board not available in this team')
+ })
+
+ it('reports missing support without redirecting to the Deck app', async () => {
+ const { wrapper, router } = await mountView({ available: false })
+ expect(wrapper.text()).toContain('Deck integration unavailable')
+ expect(wrapper.find('nextcloud-deck-board').exists()).toBe(false)
+ expect(router.currentRoute.value.path).toBe('/team/team-1/deck/7')
+ })
+
+ it('rejects invalid identifiers visibly', async () => {
+ const { wrapper } = await mountView({ cardId: '0' })
+ expect(wrapper.text()).toContain('Invalid Deck resource')
+ expect(wrapper.find('nextcloud-deck-board').exists()).toBe(false)
+ expect(logger.error).toHaveBeenCalled()
+ })
+
+ it('updates the card attribute without remounting the element', async () => {
+ const { wrapper } = await mountView()
+ const element = wrapper.get('nextcloud-deck-board').element
+ await wrapper.setProps({ cardId: '9' })
+ await flushPromises()
+ expect(wrapper.get('nextcloud-deck-board').element).toBe(element)
+ expect(wrapper.get('nextcloud-deck-board').attributes('card-id')).toBe('9')
+ expect(element.querySelector('div')!.getAttribute('data-card-id')).toBe('9')
+ await wrapper.setProps({ cardId: undefined })
+ await flushPromises()
+ expect(wrapper.get('nextcloud-deck-board').element).toBe(element)
+ expect(wrapper.get('nextcloud-deck-board').attributes('card-id')).toBeUndefined()
+ expect(element.querySelector('div')!.getAttribute('data-card-id')).toBeNull()
+ })
+
+ it('preserves the board on actual route transitions and browser history', async () => {
+ const { wrapper, router } = await mountView({ routed: true })
+ const element = wrapper.get('nextcloud-deck-board').element
+ await router.push('/team/team-1/deck/7/card/9')
+ await flushPromises()
+ expect(wrapper.get('nextcloud-deck-board').element).toBe(element)
+ expect(element.getAttribute('card-id')).toBe('9')
+ router.back()
+ await flushPromises()
+ expect(wrapper.get('nextcloud-deck-board').element).toBe(element)
+ expect(element.getAttribute('card-id')).toBeNull()
+ router.forward()
+ await flushPromises()
+ expect(element.getAttribute('card-id')).toBe('9')
+ await router.push('/team/team-1/deck/8')
+ await flushPromises()
+ expect(wrapper.get('nextcloud-deck-board').element).not.toBe(element)
+ expect(element.isConnected).toBe(false)
+ })
+
+ it('reports failed resource loading instead of waiting forever', async () => {
+ const { wrapper, state } = await mountView({ loaded: false })
+ state.boardsChecked = true
+ await flushPromises()
+ expect(wrapper.text()).toContain('Team resources unavailable')
+ expect(wrapper.find('nextcloud-deck-board').exists()).toBe(false)
+ })
+
+ it('removes the loading indicator when Deck signals readiness', async () => {
+ const { wrapper } = await mountView()
+ expect(wrapper.find('.team-deck__loading').exists()).toBe(true)
+ wrapper.get('nextcloud-deck-board').element.dispatchEvent(new CustomEvent('deck-ready', { detail: { boardId: 7 } }))
+ await flushPromises()
+ expect(wrapper.find('.team-deck__loading').exists()).toBe(false)
+ })
+
+ it('uses host history for card navigation and ignores route echoes', async () => {
+ const { wrapper, router } = await mountView()
+ const element = wrapper.get('nextcloud-deck-board').element
+ element.dispatchEvent(new CustomEvent('deck-navigation', { detail: { boardId: 7, cardId: 9 } }))
+ await flushPromises()
+ expect(router.currentRoute.value.path).toBe('/team/team-1/deck/7/card/9')
+ const push = vi.spyOn(router, 'push')
+ element.dispatchEvent(new CustomEvent('deck-navigation', { detail: { boardId: 7, cardId: 9 } }))
+ await flushPromises()
+ expect(push).not.toHaveBeenCalled()
+ })
+
+ it('allows cross-board navigation only within the same team resources', async () => {
+ const { wrapper, router } = await mountView()
+ const element = wrapper.get('nextcloud-deck-board').element
+ element.dispatchEvent(new CustomEvent('deck-navigation', { detail: { boardId: 8, cardId: null } }))
+ await flushPromises()
+ expect(router.currentRoute.value.path).toBe('/team/team-1/deck/8')
+ element.dispatchEvent(new CustomEvent('deck-navigation', { detail: { boardId: 99, cardId: 9 } }))
+ await flushPromises()
+ expect(router.currentRoute.value.path).toBe('/team/team-1/deck/8')
+ expect(showError).toHaveBeenCalledWith('This board is not shared with the current team')
+ })
+
+ it('reports malformed navigation events', async () => {
+ const { wrapper } = await mountView()
+ wrapper.get('nextcloud-deck-board').element.dispatchEvent(new CustomEvent('deck-navigation', { detail: { boardId: -1, cardId: null } }))
+ expect(logger.error).toHaveBeenCalled()
+ expect(showError).toHaveBeenCalledWith('Could not open the Deck resource')
+ })
+
+ it('reports loading errors and can retry with a fresh element', async () => {
+ const { wrapper } = await mountView()
+ const element = wrapper.get('nextcloud-deck-board').element
+ element.dispatchEvent(new CustomEvent('deck-error', { detail: { status: 403 } }))
+ await flushPromises()
+ expect(wrapper.text()).toContain('Deck board unavailable')
+ expect(wrapper.find('nextcloud-deck-board').exists()).toBe(false)
+ await wrapper.get('button').trigger('click')
+ await flushPromises()
+ expect(wrapper.get('nextcloud-deck-board').element).not.toBe(element)
+ })
+
+ it('reports a registration timeout rather than loading forever', async () => {
+ vi.useFakeTimers()
+ vi.spyOn(customElements, 'whenDefined').mockReturnValue(new Promise(() => {}))
+ const { wrapper } = await mountView()
+ await vi.advanceTimersByTimeAsync(9999)
+ expect(wrapper.text()).not.toContain('Deck board unavailable')
+ await vi.advanceTimersByTimeAsync(1)
+ expect(wrapper.text()).toContain('Deck board unavailable')
+ expect(logger.error).toHaveBeenCalled()
+ })
+})
diff --git a/src/teams/views/TeamDeckView.vue b/src/teams/views/TeamDeckView.vue
new file mode 100644
index 000000000..271078618
--- /dev/null
+++ b/src/teams/views/TeamDeckView.vue
@@ -0,0 +1,199 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('circles', 'Retry') }}
+
+
+
+
+
+
+
+
diff --git a/src/teams/views/TeamHomeView.spec.ts b/src/teams/views/TeamHomeView.spec.ts
new file mode 100644
index 000000000..008b2f7a3
--- /dev/null
+++ b/src/teams/views/TeamHomeView.spec.ts
@@ -0,0 +1,62 @@
+/*!
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { shallowMount } from '@vue/test-utils'
+import { createPinia } from 'pinia'
+import { describe, expect, it, vi } from 'vitest'
+import TeamHomeView from './TeamHomeView.vue'
+import { useTeamResourcesStore } from '../resourcesStore.ts'
+
+vi.mock('vuex', () => ({ useStore: () => ({ getters: { getCircle: () => ({ isMember: true }) } }) }))
+vi.mock('../api.ts', () => ({
+ getTeamFolder: vi.fn(),
+ upgradeTeamFolder: vi.fn(),
+ fetchTeamPages: vi.fn(),
+ fetchTeamResources: vi.fn(),
+ fetchTeamDeckBoards: vi.fn(),
+ fetchTabOrder: vi.fn(),
+ saveTabOrder: vi.fn(),
+}))
+
+describe('shared team resource links', () => {
+ it('embeds shared Deck boards while preserving links to other apps', () => {
+ const pinia = createPinia()
+ const state = useTeamResourcesStore(pinia).slot('team-1')
+ state.resourcesChecked = true
+ state.boardsChecked = true
+ state.folderChecked = true
+ state.boards = [{ id: 7, title: 'Team board', url: '/apps/deck/board/7' }]
+ state.resources = [
+ { id: 7, label: 'Team board', url: '/apps/deck/board/7', provider: { id: 'deck', name: 'Deck' } },
+ { id: 8, label: 'Shared board', url: '/apps/deck/board/8', provider: { id: 'deck', name: 'Deck' } },
+ { id: 9, label: 'Team chat', url: '/apps/spreed/room/team', provider: { id: 'talk', name: 'Talk' } },
+ ]
+ const wrapper = shallowMount(TeamHomeView, {
+ props: { teamId: 'team-1' },
+ global: {
+ plugins: [pinia],
+ stubs: {
+ NcListItem: {
+ name: 'NcListItem',
+ props: ['name', 'to', 'href'],
+ template: '',
+ },
+ },
+ },
+ })
+ const entries = wrapper.findAllComponents({ name: 'NcListItem' })
+ expect(entries).toHaveLength(2)
+ const board = entries.find((entry) => entry.props('name') === 'Shared board')!
+ expect(board.props('href')).toBeUndefined()
+ expect(board.props('to')).toEqual({
+ name: 'team-deck',
+ params: { teamId: 'team-1', boardId: '8' },
+ })
+ const chat = entries.find((entry) => entry.props('name') === 'Team chat')!
+ expect(chat.props('href')).toBe('/apps/spreed/room/team')
+ expect(chat.props('to')).toBeUndefined()
+ wrapper.unmount()
+ })
+})
diff --git a/src/teams/views/TeamHomeView.vue b/src/teams/views/TeamHomeView.vue
index c582a83de..85ed12d6f 100644
--- a/src/teams/views/TeamHomeView.vue
+++ b/src/teams/views/TeamHomeView.vue
@@ -16,6 +16,7 @@ import ListItem from '@nextcloud/vue/components/NcListItem'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import FileDocumentOutline from 'vue-material-design-icons/FileDocumentOutline.vue'
import ContentHeading from '../team-page/components/CircleDetails/ContentHeading.vue'
+import { teamDeckRoute } from '../deck.ts'
import { useTeamResourcesStore } from '../resourcesStore.ts'
const props = defineProps<{
@@ -107,7 +108,8 @@ const groupedResources = computed(() => {
resourcesStore.forTeam(props.teamId))
diff --git a/src/teams/views/TeamPage.vue b/src/teams/views/TeamPage.vue
index b55738a1a..f8813dae0 100644
--- a/src/teams/views/TeamPage.vue
+++ b/src/teams/views/TeamPage.vue
@@ -14,6 +14,7 @@ import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import { logger } from '../../logger.ts'
+import { teamViewKey } from '../deck.ts'
import { useTeamResourcesStore } from '../resourcesStore.ts'
import { joinCircle } from '../team-page/services/circles.ts'
@@ -118,9 +119,9 @@ async function onRequestJoin(): Promise {
-
-
+
+
diff --git a/tests/stubs/oca_deck_event_loadboardwebcomponentevent.php b/tests/stubs/oca_deck_event_loadboardwebcomponentevent.php
new file mode 100644
index 000000000..5cc43f8a6
--- /dev/null
+++ b/tests/stubs/oca_deck_event_loadboardwebcomponentevent.php
@@ -0,0 +1,15 @@
+teamManager = $this->createMock(ITeamManager::class);
$this->teamFolderPolicy = $this->createMock(TeamFolderPolicy::class);
$this->eventDispatcher = $this->createMock(IEventDispatcher::class);
+ $this->appManager = $this->createMock(IAppManager::class);
$this->pageController = new PageController(
$this->request,
@@ -49,6 +53,7 @@ protected function setUp(): void {
$this->teamManager,
$this->teamFolderPolicy,
$this->eventDispatcher,
+ $this->appManager,
);
}
@@ -72,7 +77,7 @@ public function testIndexProvidesTeamFolderStateWithProvider(): void {
->willReturn(true);
$provided = [];
- $this->initialState->expects($this->exactly(2))
+ $this->initialState->expects($this->exactly(3))
->method('provideInitialState')
->willReturnCallback(function (string $key, mixed $value) use (&$provided): void {
$provided[$key] = $value;
@@ -85,6 +90,7 @@ public function testIndexProvidesTeamFolderStateWithProvider(): void {
$this->assertSame('main', $result->getTemplateName());
$this->assertTrue($provided['teamFolderProviderAvailable']);
$this->assertTrue($provided['teamFolderProvisioningEnabled']);
+ $this->assertFalse($provided['deckEmbeddingAvailable']);
}
/**
@@ -106,7 +112,7 @@ public function testIndexProvidesProviderUnavailable(): void {
->willReturn(false);
$provided = [];
- $this->initialState->expects($this->exactly(2))
+ $this->initialState->expects($this->exactly(3))
->method('provideInitialState')
->willReturnCallback(function (string $key, mixed $value) use (&$provided): void {
$provided[$key] = $value;
@@ -117,6 +123,7 @@ public function testIndexProvidesProviderUnavailable(): void {
$this->assertInstanceOf(TemplateResponse::class, $result);
$this->assertFalse($provided['teamFolderProviderAvailable']);
$this->assertFalse($provided['teamFolderProvisioningEnabled']);
+ $this->assertFalse($provided['deckEmbeddingAvailable']);
}
/**
@@ -135,9 +142,44 @@ public function testIndexReturnsNotFoundWhenFrontendDisabled(): void {
->method('getTeamFolderProvider');
$this->teamFolderPolicy->expects($this->never())
->method('isTeamFolderProvisioningEnabled');
+ $this->appManager->expects($this->never())->method('isEnabledForUser');
$result = $this->pageController->index();
$this->assertInstanceOf(NotFoundResponse::class, $result);
}
+
+ public function testLoadsDeckOnlyWhenEnabledAndSupported(): void {
+ $this->configService->method('getAppValueBool')->willReturn(true);
+ $this->appManager->expects($this->once())->method('isEnabledForUser')
+ ->with('deck')->willReturn(true);
+ $provided = [];
+ $this->initialState->method('provideInitialState')
+ ->willReturnCallback(static function (string $key, mixed $value) use (&$provided): void {
+ $provided[$key] = $value;
+ });
+ $events = [];
+ $this->eventDispatcher->method('dispatchTyped')
+ ->willReturnCallback(static function (object $event) use (&$events): void {
+ $events[] = get_class($event);
+ });
+
+ $this->pageController->index();
+
+ $supported = class_exists(LoadBoardWebComponentEvent::class);
+ $this->assertSame($supported, $provided['deckEmbeddingAvailable']);
+ $this->assertSame($supported, in_array(LoadBoardWebComponentEvent::class, $events, true));
+ }
+
+ public function testDisabledDeckDoesNotDispatchItsLoadingEvent(): void {
+ $this->configService->method('getAppValueBool')->willReturn(true);
+ $this->appManager->expects($this->once())->method('isEnabledForUser')
+ ->with('deck')->willReturn(false);
+ $this->eventDispatcher->method('dispatchTyped')
+ ->willReturnCallback(function (object $event): void {
+ $this->assertNotSame(LoadBoardWebComponentEvent::class, get_class($event));
+ });
+
+ $this->pageController->index();
+ }
}
diff --git a/vite.config.ts b/vite.config.ts
index 1c56a21d9..52aef6c79 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -3,6 +3,9 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
+import type { Api } from '@vitejs/plugin-vue'
+import type { Plugin } from 'vite'
+
import { createAppConfig } from '@nextcloud/vite-config'
import { join } from 'node:path'
@@ -20,6 +23,25 @@ export default (env) => createAppConfig({
includeSourceMaps: true,
},
config: {
+ plugins: [{
+ name: 'teams-deck-custom-element',
+ configResolved(config) {
+ const plugin = config.plugins.find((candidate): candidate is Plugin => candidate.name === 'vite:vue')
+ if (!plugin?.api) {
+ throw new Error('The Vue compiler is required for the Deck custom element')
+ }
+ const template = plugin.api.options.template ?? {}
+ const compilerOptions = template.compilerOptions ?? {}
+ const previous = compilerOptions.isCustomElement
+ plugin.api.options.template = {
+ ...template,
+ compilerOptions: {
+ ...compilerOptions,
+ isCustomElement: (tag) => tag === 'nextcloud-deck-board' || previous?.(tag) === true,
+ },
+ }
+ },
+ }],
build: {
watch: process.argv.includes('--watch')
? { allowInputInsideOutputPath: true }
diff --git a/vitest.config.ts b/vitest.config.ts
index 0d640a1f8..7be824e4d 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -9,7 +9,13 @@ import { defineConfig } from 'vitest/config'
// Vitest uses this config in preference to vite.config.ts (the app build config),
// so the production build and the test runner stay isolated from each other.
export default defineConfig({
- plugins: [vue()],
+ plugins: [vue({
+ template: {
+ compilerOptions: {
+ isCustomElement: (tag) => tag === 'nextcloud-deck-board',
+ },
+ },
+ })],
test: {
// Co-locate specs next to the code they cover: src/**/*.spec.ts (or .test.ts).
include: ['src/**/*.{test,spec}.?(c|m)[jt]s?(x)'],