From a52d284375a29a94ec8d9ebe756b8e3a74746ae1 Mon Sep 17 00:00:00 2001 From: Stefan Lender Date: Thu, 15 Oct 2026 00:09:51 +0200 Subject: [PATCH] feat: add team deck integration with routing and component support Co-authored-by: Copilot GPT-6.1 Sol Signed-off-by: Stefan Lender --- eslint.config.js | 9 + lib/Controller/PageController.php | 10 + psalm.xml | 1 + src/teams/components/TeamNavigation.spec.ts | 105 ++++++++ src/teams/components/TeamNavigation.vue | 7 +- src/teams/deck.spec.ts | 45 ++++ src/teams/deck.ts | 57 ++++ src/teams/firstTab.spec.ts | 13 +- src/teams/firstTab.ts | 14 +- src/teams/router.ts | 13 + src/teams/views/TeamDeckView.spec.ts | 248 ++++++++++++++++++ src/teams/views/TeamDeckView.vue | 199 ++++++++++++++ src/teams/views/TeamHomeView.spec.ts | 62 +++++ src/teams/views/TeamHomeView.vue | 4 +- src/teams/views/TeamLandingView.vue | 2 +- src/teams/views/TeamPage.vue | 7 +- ..._deck_event_loadboardwebcomponentevent.php | 15 ++ .../lib/Controller/PageControllerTest.php | 46 +++- vite.config.ts | 22 ++ vitest.config.ts | 8 +- 20 files changed, 872 insertions(+), 15 deletions(-) create mode 100644 src/teams/components/TeamNavigation.spec.ts create mode 100644 src/teams/deck.spec.ts create mode 100644 src/teams/deck.ts create mode 100644 src/teams/views/TeamDeckView.spec.ts create mode 100644 src/teams/views/TeamDeckView.vue create mode 100644 src/teams/views/TeamHomeView.spec.ts create mode 100644 tests/stubs/oca_deck_event_loadboardwebcomponentevent.php 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 @@ + + + + + + + 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(() => {