Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
},
]
10 changes: 10 additions & 0 deletions lib/Controller/PageController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
}
Expand All @@ -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)) {
Expand Down
1 change: 1 addition & 0 deletions psalm.xml
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
<file name="tests/stubs/oc_user_nouserexception.php" />
<file name="tests/stubs/oc_user_user.php" />
<file name="tests/stubs/oca_dav_carddav_contactsmanager.php" />
<file name="tests/stubs/oca_deck_event_loadboardwebcomponentevent.php" />
<file name="tests/stubs/oca_federatedfilesharing_notifications.php" />
<file name="tests/stubs/oca_files_sharing_event_usershareaccessupdatedevent.php" />
<file name="tests/stubs/oca_files_sharing_external_storage.php" />
Expand Down
105 changes: 105 additions & 0 deletions src/teams/components/TeamNavigation.spec.ts
Original file line number Diff line number Diff line change
@@ -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: '<nav><slot name="list" /></nav>' },
NcAppNavigationItem: {
name: 'NcAppNavigationItem',
props: ['name', 'to', 'href', 'active'],
template: '<div />',
},
},
},
})
}

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()
})
})
7 changes: 6 additions & 1 deletion src/teams/components/TeamNavigation.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -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
}
Expand Down Expand Up @@ -124,7 +126,8 @@ const entries = computed<NavigationEntry[]>(() => {
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) {
Expand All @@ -142,6 +145,7 @@ const entries = computed<NavigationEntry[]>(() => {
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
})
Expand Down Expand Up @@ -538,6 +542,7 @@ async function onEntryDragEnd(): Promise<void> {
: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')"
Expand Down
45 changes: 45 additions & 0 deletions src/teams/deck.spec.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
57 changes: 57 additions & 0 deletions src/teams/deck.ts
Original file line number Diff line number Diff line change
@@ -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<RouteLocationNormalizedLoaded, 'name' | 'params' | 'path'>): 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
}
13 changes: 12 additions & 1 deletion src/teams/firstTab.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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)
})

Expand Down
14 changes: 9 additions & 5 deletions src/teams/firstTab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,31 +6,35 @@
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
*/
export function firstTabRoute(teamId: string, resources: TeamResources): RouteLocationRaw {
const targets = new Map<string, RouteLocationRaw>()
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) } })
}
Expand Down
Loading
Loading