From be6e6708c87d17c0ed05ab05185ad81d88dd4f95 Mon Sep 17 00:00:00 2001 From: Janina Hiob Date: Wed, 21 Oct 2026 11:23:46 +0200 Subject: [PATCH] feat: display team specific dashboards Signed-off-by: Janina Hiob Assisted-by: Copilot:gpt-6.1 sol --- appinfo/routes.php | 2 + .../TeamDashboardSettingsController.php | 96 ++++++ lib/Service/TeamDashboardSettingsService.php | 47 +++ src/teams/api.spec.ts | 38 ++- src/teams/api.ts | 55 +++- .../TeamDashboardSettingsDialog.spec.ts | 83 +++++ .../TeamDashboardSettingsDialog.vue | 136 ++++++++ .../components/TeamDashboardWidget.spec.ts | 78 +++++ src/teams/components/TeamDashboardWidget.vue | 107 +++++++ src/teams/components/TeamNavigation.vue | 6 + src/teams/firstTab.spec.ts | 9 +- src/teams/firstTab.ts | 1 + src/teams/router.ts | 7 + src/teams/types.ts | 34 ++ src/teams/views/TeamOverviewView.spec.ts | 300 ++++++++++++++++++ src/teams/views/TeamOverviewView.vue | 274 ++++++++++++++++ .../TeamDashboardSettingsControllerTest.php | 100 ++++++ .../TeamDashboardSettingsServiceTest.php | 58 ++++ 18 files changed, 1427 insertions(+), 4 deletions(-) create mode 100644 lib/Controller/TeamDashboardSettingsController.php create mode 100644 lib/Service/TeamDashboardSettingsService.php create mode 100644 src/teams/components/TeamDashboardSettingsDialog.spec.ts create mode 100644 src/teams/components/TeamDashboardSettingsDialog.vue create mode 100644 src/teams/components/TeamDashboardWidget.spec.ts create mode 100644 src/teams/components/TeamDashboardWidget.vue create mode 100644 src/teams/views/TeamOverviewView.spec.ts create mode 100644 src/teams/views/TeamOverviewView.vue create mode 100644 tests/unit/Controller/TeamDashboardSettingsControllerTest.php create mode 100644 tests/unit/Service/TeamDashboardSettingsServiceTest.php diff --git a/appinfo/routes.php b/appinfo/routes.php index 5ae7dbd85..28a02477f 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -50,6 +50,8 @@ ['name' => 'TeamFolder#upgradeTeamFolder', 'url' => '/teams/{circleId}/folder', 'verb' => 'POST'], ['name' => 'TeamTabs#getTabOrder', 'url' => '/teams/{circleId}/tab-order', 'verb' => 'GET'], ['name' => 'TeamTabs#setTabOrder', 'url' => '/teams/{circleId}/tab-order', 'verb' => 'PUT'], + ['name' => 'TeamDashboardSettings#getLayout', 'url' => '/teams/{circleId}/dashboard/layout', 'verb' => 'GET'], + ['name' => 'TeamDashboardSettings#setLayout', 'url' => '/teams/{circleId}/dashboard/layout', 'verb' => 'PUT'], ['name' => 'TeamFolder#getLinkableTeamFolders', 'url' => '/teams/{circleId}/folder/linkable', 'verb' => 'GET'], ['name' => 'TeamFolder#linkTeamFolder', 'url' => '/teams/{circleId}/folder/link', 'verb' => 'POST'], ['name' => 'TeamFolder#updateTeamFolderQuota', 'url' => '/teams/{circleId}/folder/quota', 'verb' => 'PUT'], diff --git a/lib/Controller/TeamDashboardSettingsController.php b/lib/Controller/TeamDashboardSettingsController.php new file mode 100644 index 000000000..b93177170 --- /dev/null +++ b/lib/Controller/TeamDashboardSettingsController.php @@ -0,0 +1,96 @@ +userSession->getUser(); + if ($user === null || !$this->teamManager->isMemberOfTeam($circleId, $user->getUID())) { + throw new OCSNotFoundException('Team not found'); + } + try { + return $this->permissionService->userMustBeMember($user->getUID(), $circleId); + } catch (InsufficientPermissionException) { + throw new OCSNotFoundException('Team not found'); + } + } + + private function canEdit(Member $member): bool { + try { + $this->permissionService->memberMustBeAtLeastAdmin($member); + return true; + } catch (InsufficientPermissionException) { + return false; + } + } + + private function readWidgetIds(string $circleId): ?array { + try { + $circle = $this->circleRequest->getCircle($circleId); + } catch (CircleNotFoundException) { + throw new OCSNotFoundException('Team not found'); + } + return $this->settings->decode($circle->getSettings()[TeamDashboardSettingsService::SETTING] ?? null); + } + + #[NoAdminRequired] + public function getLayout(string $circleId): DataResponse { + $member = $this->authorize($circleId); + return new DataResponse(['widgetIds' => $this->readWidgetIds($circleId), 'canEdit' => $this->canEdit($member)]); + } + + #[NoAdminRequired] + public function setLayout(string $circleId, mixed $widgetIds): DataResponse { + $member = $this->authorize($circleId); + if (!$this->canEdit($member)) { + throw new OCSException('Only team admins can change dashboards', 403); + } + if ($widgetIds !== null) { + $allowedIds = array_merge(array_keys($this->dashboardManager->getTeamWidgets()), $this->readWidgetIds($circleId) ?? []); + $widgetIds = $this->settings->validate($widgetIds, $allowedIds); + } + $this->federatedUserService->setLocalCurrentUser($this->userSession->getUser()); + $this->circleService->updateSetting($circleId, TeamDashboardSettingsService::SETTING, json_encode($widgetIds, JSON_THROW_ON_ERROR)); + return new DataResponse(['widgetIds' => $widgetIds, 'canEdit' => true]); + } +} diff --git a/lib/Service/TeamDashboardSettingsService.php b/lib/Service/TeamDashboardSettingsService.php new file mode 100644 index 000000000..ed9f5d1e5 --- /dev/null +++ b/lib/Service/TeamDashboardSettingsService.php @@ -0,0 +1,47 @@ +validate(json_decode($value, false, 32, JSON_THROW_ON_ERROR)); + } catch (JsonException|OCSBadRequestException $exception) { + throw new OCSException('Invalid dashboard settings', 500, $exception); + } + } + + public function validate(mixed $widgetIds, ?array $allowedIds = null): ?array { + if ($widgetIds === null) { + return null; + } + if (!is_array($widgetIds) || !array_is_list($widgetIds) || count($widgetIds) > 100) { + throw new OCSBadRequestException('Expected at most 100 widget identifiers'); + } + foreach ($widgetIds as $id) { + if (!is_string($id) || strlen($id) > 64 || !preg_match('/^[a-z][a-z0-9_-]*$/D', $id)) { + throw new OCSBadRequestException('Invalid widget identifier'); + } + if ($allowedIds !== null && !in_array($id, $allowedIds, true)) { + throw new OCSBadRequestException('Unknown team widget'); + } + } + return array_values(array_unique($widgetIds)); + } +} diff --git a/src/teams/api.spec.ts b/src/teams/api.spec.ts index b8149538a..3e672fd23 100644 --- a/src/teams/api.spec.ts +++ b/src/teams/api.spec.ts @@ -32,7 +32,43 @@ vi.mock('./team-page/services/collaborationAutocompletion.js', () => ({ getSuggestions: vi.fn(), })) -const { createTeam, fetchTeams, joinTeam, setTeamFavorite, reorderFavoriteTeams } = await import('./api.ts') +const { createTeam, fetchTeams, joinTeam, setTeamFavorite, reorderFavoriteTeams, fetchTeamDashboardWidgets, fetchTeamDashboardItems, fetchTeamDashboardLayout, saveTeamDashboardLayout } = await import('./api.ts') + +describe('team dashboard', () => { + it('loads metadata from the team-scoped endpoint with cancellation', async () => { + const widget = { id: 'pages', title: 'Recent pages' } + vi.mocked(axios.get).mockResolvedValueOnce({ data: { ocs: { data: { pages: widget } } } }) + const signal = new AbortController().signal + await expect(fetchTeamDashboardWidgets('team-a', signal)).resolves.toEqual([widget]) + expect(axios.get).toHaveBeenLastCalledWith(expect.stringContaining('apps/dashboard/api/v1/teams/{teamId}/widgets'), { signal }) + }) + + it('requests only the selected widget and fails closed on missing data', async () => { + vi.mocked(axios.get).mockResolvedValueOnce({ data: { ocs: { data: {} } } }) + await expect(fetchTeamDashboardItems('team-a', 'pages')).resolves.toEqual({ status: 'error' }) + expect(axios.get).toHaveBeenLastCalledWith(expect.stringContaining('widget-items'), { signal: undefined, params: { widgets: ['pages'], limit: 7 } }) + }) + + it('loads the shared team layout with cancellation', async () => { + const layout = { widgetIds: ['deck-team-today'], canEdit: false } + const signal = new AbortController().signal + vi.mocked(axios.get).mockResolvedValueOnce({ data: { ocs: { data: layout } } }) + await expect(fetchTeamDashboardLayout('team-a', signal)).resolves.toEqual(layout) + expect(axios.get).toHaveBeenLastCalledWith('/ocs/apps/circles/teams/{teamId}/dashboard/layout', { signal }) + }) + + it('preserves an explicitly empty selection in a save', async () => { + vi.mocked(axios.put).mockResolvedValueOnce({ data: { ocs: { data: { widgetIds: [], canEdit: true } } } }) + await expect(saveTeamDashboardLayout('team-a', [])).resolves.toEqual({ widgetIds: [], canEdit: true }) + expect(axios.put).toHaveBeenLastCalledWith('/ocs/apps/circles/teams/{teamId}/dashboard/layout', { widgetIds: [] }, { signal: undefined }) + }) + + it('restores automatic defaults by saving null', async () => { + vi.mocked(axios.put).mockResolvedValueOnce({ data: { ocs: { data: { widgetIds: null, canEdit: true } } } }) + await saveTeamDashboardLayout('team-a', null) + expect(axios.put).toHaveBeenLastCalledWith('/ocs/apps/circles/teams/{teamId}/dashboard/layout', { widgetIds: null }, { signal: undefined }) + }) +}) describe('fetchTeams', () => { it('splits active members from discoverable teams', async () => { diff --git a/src/teams/api.ts b/src/teams/api.ts index 49dd4e4c2..6ceb28a92 100644 --- a/src/teams/api.ts +++ b/src/teams/api.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import type { DiscoverableTeam, Member, MemberCandidate, Resource, SharedResource, Team, TeamRole } from './types.ts' +import type { DiscoverableTeam, Member, MemberCandidate, Resource, SharedResource, Team, TeamDashboardLayout, TeamDashboardResult, TeamDashboardWidget, TeamRole } from './types.ts' import axios from '@nextcloud/axios' import { FileType } from '@nextcloud/files' @@ -590,6 +590,59 @@ export async function fetchTeamResources(teamId: string): Promise { + const response = await axios.get>>( + generateOcsUrl('apps/dashboard/api/v1/teams/{teamId}/widgets', { teamId }), + { signal }, + ) + return Object.values(response.data.ocs.data ?? {}) +} + +/** + * + * @param teamId - Team scope for the content + * @param widgetId - Registered team-aware widget + * @param signal - Cancels requests when the view changes + */ +export async function fetchTeamDashboardItems(teamId: string, widgetId: string, signal?: AbortSignal): Promise { + const response = await axios.get>>( + generateOcsUrl('apps/dashboard/api/v1/teams/{teamId}/widget-items', { teamId }), + { signal, params: { widgets: [widgetId], limit: 7 } }, + ) + return response.data.ocs.data?.[widgetId] ?? { status: 'error' } +} + +/** + * @param teamId - Team whose shared selection is requested + * @param signal - Cancels requests on team changes + */ +export async function fetchTeamDashboardLayout(teamId: string, signal?: AbortSignal): Promise { + const response = await axios.get>( + generateOcsUrl('apps/circles/teams/{teamId}/dashboard/layout', { teamId }), + { signal }, + ) + return response.data.ocs.data +} + +/** + * @param teamId - Team whose shared selection is updated + * @param widgetIds - Explicit selection, or null to restore automatic defaults + * @param signal - Cancels requests on team changes + */ +export async function saveTeamDashboardLayout(teamId: string, widgetIds: string[] | null, signal?: AbortSignal): Promise { + const response = await axios.put>( + generateOcsUrl('apps/circles/teams/{teamId}/dashboard/layout', { teamId }), + { widgetIds }, + { signal }, + ) + return response.data.ocs.data +} + /** * Create a collective named after a team. The collectives app links the * collective to the team by name, so no separate share step is needed. diff --git a/src/teams/components/TeamDashboardSettingsDialog.spec.ts b/src/teams/components/TeamDashboardSettingsDialog.spec.ts new file mode 100644 index 000000000..a3f0b2ce7 --- /dev/null +++ b/src/teams/components/TeamDashboardSettingsDialog.spec.ts @@ -0,0 +1,83 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type * as L10n from '@nextcloud/l10n' +import type { TeamDashboardWidget } from '../types.ts' + +import { flushPromises, mount } from '@vue/test-utils' +import { describe, expect, it, vi } from 'vitest' +import TeamDashboardSettingsDialog from './TeamDashboardSettingsDialog.vue' + +vi.mock('@nextcloud/l10n', async (importOriginal) => ({ + ...await importOriginal(), + t: (_app: string, text: string) => text, +})) + +const catalog: TeamDashboardWidget[] = ['today', 'tomorrow', 'upcoming'].map((period) => ({ + id: `deck-team-${period}`, + title: `Deck ${period}`, + order: 20, + icon_url: '', + icon_class: '', + item_icons_round: false, +})) + +function render(widgetIds: string[] | null, saveLayout = vi.fn(async () => true)) { + const wrapper = mount(TeamDashboardSettingsDialog, { + props: { catalog, widgetIds, saving: false, saveLayout }, + global: { stubs: { + NcDialog: { template: '
' }, + NcCheckboxRadioSwitch: { + props: ['modelValue', 'disabled'], + emits: ['update:modelValue'], + template: '', + }, + NcButton: { template: '' }, + NcIconSvgWrapper: true, + } }, + }) + return { wrapper, saveLayout } +} + +describe('TeamDashboardSettingsDialog', () => { + it('offers all three independent Deck variants', () => { + const { wrapper } = render(['deck-team-today']) + expect(wrapper.findAll('input')).toHaveLength(3) + expect((wrapper.findAll('input')[0]!.element as HTMLInputElement).checked).toBe(true) + expect((wrapper.findAll('input')[1]!.element as HTMLInputElement).checked).toBe(false) + wrapper.unmount() + }) + + it('keeps draft edits local until save and submits an explicit empty list', async () => { + const { wrapper, saveLayout } = render(['deck-team-today']) + await wrapper.findAll('input')[0]!.setValue(false) + expect(saveLayout).not.toHaveBeenCalled() + await wrapper.findAll('button').find((button) => button.text() === 'Save')!.trigger('click') + await flushPromises() + expect(saveLayout).toHaveBeenCalledWith([]) + expect(wrapper.emitted('close')).toHaveLength(1) + wrapper.unmount() + }) + + it('keeps the dialog open with its draft on save failure', async () => { + const saveLayout = vi.fn(async () => { + throw new Error('Network') + }) + const { wrapper } = render(['deck-team-today'], saveLayout) + await wrapper.findAll('button').find((button) => button.text() === 'Save')!.trigger('click') + await flushPromises() + expect(wrapper.get('[role="alert"]').text()).toContain('Could not save') + expect(wrapper.emitted('close')).toBeUndefined() + wrapper.unmount() + }) + + it('preserves a saved selection whose app is unavailable', () => { + const { wrapper } = render(['disabled-widget']) + expect(wrapper.text()).toContain('disabled-widget') + expect(wrapper.text()).toContain('Unavailable') + expect((wrapper.findAll('input')[3]!.element as HTMLInputElement).checked).toBe(true) + wrapper.unmount() + }) +}) diff --git a/src/teams/components/TeamDashboardSettingsDialog.vue b/src/teams/components/TeamDashboardSettingsDialog.vue new file mode 100644 index 000000000..4cdf4726a --- /dev/null +++ b/src/teams/components/TeamDashboardSettingsDialog.vue @@ -0,0 +1,136 @@ + + + + + + + diff --git a/src/teams/components/TeamDashboardWidget.spec.ts b/src/teams/components/TeamDashboardWidget.spec.ts new file mode 100644 index 000000000..8824d71e1 --- /dev/null +++ b/src/teams/components/TeamDashboardWidget.spec.ts @@ -0,0 +1,78 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type * as L10n from '@nextcloud/l10n' +import type { TeamDashboardResult, TeamDashboardWidget } from '../types.ts' + +import { mount } from '@vue/test-utils' +import { describe, expect, it, vi } from 'vitest' +import TeamDashboardWidgetComponent from './TeamDashboardWidget.vue' + +vi.mock('@nextcloud/l10n', async (importOriginal) => ({ + ...await importOriginal(), + t: (_app: string, text: string) => text, +})) + +const widget: TeamDashboardWidget = { id: 'pages', title: 'Recent pages', order: 6, icon_url: '', icon_class: '', item_icons_round: false } + +function render(result: TeamDashboardResult) { + return mount(TeamDashboardWidgetComponent, { + props: { widget, result, loading: false }, + global: { + stubs: { + NcDashboardWidget: { + props: ['items'], + template: '
', + }, + NcDashboardWidgetItem: { + props: ['mainText', 'subText', 'targetUrl'], + template: '{{ mainText }} {{ subText }}', + }, + NcEmptyContent: { props: ['name'], template: '
{{ name }}
' }, + NcButton: { template: '' }, + NcIconSvgWrapper: true, + }, + }, + }) +} + +describe('TeamDashboardWidget', () => { + it('renders team-provided page titles and links', () => { + const wrapper = render({ status: 'ok', data: { + items: [{ title: 'Team handbook', subtitle: 'Notes', link: '/apps/collectives/team-a/handbook', iconUrl: '/page.png', overlayIconUrl: '', sinceId: '1' }], + emptyContentMessage: '', + halfEmptyContentMessage: '', + } }) + expect(wrapper.get('h2').text()).toBe('Recent pages') + expect(wrapper.get('a').attributes('href')).toBe('/apps/collectives/team-a/handbook') + expect(wrapper.get('a').text()).toContain('Team handbook') + wrapper.unmount() + }) + + it('shows the provider empty-state message', () => { + const wrapper = render({ status: 'ok', data: { items: [], emptyContentMessage: 'No recent team pages', halfEmptyContentMessage: '' } }) + expect(wrapper.text()).toContain('No recent team pages') + wrapper.unmount() + }) + + it('offers an independent retry after a provider failure', async () => { + const wrapper = render({ status: 'error' }) + await wrapper.get('button').trigger('click') + expect(wrapper.emitted('retry')).toHaveLength(1) + expect(wrapper.find('a').exists()).toBe(false) + wrapper.unmount() + }) + + it('replaces a broken item icon with the standard fallback', async () => { + const wrapper = render({ status: 'ok', data: { + items: [{ title: 'Page', subtitle: '', link: '/page', iconUrl: '/broken.png', overlayIconUrl: '', sinceId: '' }], + emptyContentMessage: '', + halfEmptyContentMessage: '', + } }) + await wrapper.get('a img').trigger('error') + expect(wrapper.find('a img').exists()).toBe(false) + wrapper.unmount() + }) +}) diff --git a/src/teams/components/TeamDashboardWidget.vue b/src/teams/components/TeamDashboardWidget.vue new file mode 100644 index 000000000..fae424d43 --- /dev/null +++ b/src/teams/components/TeamDashboardWidget.vue @@ -0,0 +1,107 @@ + + + + + + + diff --git a/src/teams/components/TeamNavigation.vue b/src/teams/components/TeamNavigation.vue index 133f55c48..0b4546192 100644 --- a/src/teams/components/TeamNavigation.vue +++ b/src/teams/components/TeamNavigation.vue @@ -104,6 +104,12 @@ const draggedId = ref(null) const entries = computed(() => { const items: NavigationEntry[] = [ + { + id: 'overview', + label: t('circles', 'Overview'), + icon: mdiViewDashboardOutline, + to: { name: 'team-overview', params: { teamId: teamId.value } }, + }, { id: 'team-folder', label: t('circles', 'Team folder'), diff --git a/src/teams/firstTab.spec.ts b/src/teams/firstTab.spec.ts index 7cbabcf57..ad69533b4 100644 --- a/src/teams/firstTab.spec.ts +++ b/src/teams/firstTab.spec.ts @@ -32,9 +32,14 @@ function resources(overrides: Partial = {}): TeamResources { const page = (fileId: number, title: string) => ({ fileId, title, filePath: `/Team/.pages/${title}.md` }) describe('firstTabRoute', () => { - it('falls back to the team folder without a saved order', () => { + it('opens the overview without a saved order', () => { expect(firstTabRoute(TEAM, resources())) - .toEqual({ name: 'team-folder', params: { teamId: TEAM } }) + .toEqual({ name: 'team-overview', params: { teamId: TEAM } }) + }) + + it('can place the overview first in a saved order', () => { + expect(firstTabRoute(TEAM, resources({ tabOrder: ['overview', 'home'] }))) + .toEqual({ name: 'team-overview', params: { teamId: TEAM } }) }) it('follows the saved order', () => { diff --git a/src/teams/firstTab.ts b/src/teams/firstTab.ts index f38595e50..7a0d1cad5 100644 --- a/src/teams/firstTab.ts +++ b/src/teams/firstTab.ts @@ -30,6 +30,7 @@ export function firstTabSettled(resources: TeamResources): boolean { */ export function firstTabRoute(teamId: string, resources: TeamResources): RouteLocationRaw { const targets = new Map() + targets.set('overview', { name: 'team-overview', params: { teamId } }) targets.set('team-folder', { name: 'team-folder', params: { teamId } }) 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..b4d76f2e6 100644 --- a/src/teams/router.ts +++ b/src/teams/router.ts @@ -14,6 +14,7 @@ import TeamFolderView from './views/TeamFolderView.vue' import TeamHomeView from './views/TeamHomeView.vue' import TeamLandingView from './views/TeamLandingView.vue' import TeamMembersView from './views/TeamMembersView.vue' +import TeamOverviewView from './views/TeamOverviewView.vue' import TeamPage from './views/TeamPage.vue' const routes: RouteRecordRaw[] = [ @@ -34,6 +35,12 @@ const routes: RouteRecordRaw[] = [ component: TeamPage, props: true, children: [ + { + name: 'team-overview', + path: 'overview', + component: TeamOverviewView, + props: true, + }, { // The team's own URL: forwards to the first navigation entry // once the tab order is known. The entries keep their own diff --git a/src/teams/types.ts b/src/teams/types.ts index b5889854e..b9dee2fe2 100644 --- a/src/teams/types.ts +++ b/src/teams/types.ts @@ -3,6 +3,40 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ +export interface TeamDashboardWidget { + id: string + title: string + order: number + icon_url: string + icon_class: string + item_icons_round: boolean +} + +export interface TeamDashboardLayout { + widgetIds: string[] | null + canEdit: boolean +} + +export interface TeamDashboardItem { + title: string + subtitle: string + link: string + iconUrl: string + overlayIconUrl: string + sinceId: string +} + +export interface TeamDashboardItems { + items: TeamDashboardItem[] + emptyContentMessage: string + halfEmptyContentMessage: string +} + +export type TeamDashboardResult + = | { status: 'ok', data: TeamDashboardItems } + | { status: 'unavailable' } + | { status: 'error' } + /** Permission level inside a team, derived from the circles member level. */ export type TeamRole = 'owner' | 'admin' | 'moderator' | 'member' diff --git a/src/teams/views/TeamOverviewView.spec.ts b/src/teams/views/TeamOverviewView.spec.ts new file mode 100644 index 000000000..3abece698 --- /dev/null +++ b/src/teams/views/TeamOverviewView.spec.ts @@ -0,0 +1,300 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type * as L10n from '@nextcloud/l10n' +import type { TeamDashboardLayout, TeamDashboardResult, TeamDashboardWidget } from '../types.ts' + +import { enableAutoUnmount, flushPromises, shallowMount } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ref } from 'vue' +import TeamOverviewView from './TeamOverviewView.vue' +import { fetchTeamDashboardItems, fetchTeamDashboardLayout, fetchTeamDashboardWidgets, saveTeamDashboardLayout } from '../api.ts' + +const getCircle = vi.hoisted(() => vi.fn()) +vi.mock('vuex', () => ({ useStore: () => ({ getters: { getCircle } }) })) +vi.mock('@nextcloud/l10n', async (importOriginal) => ({ + ...await importOriginal(), + t: (_app: string, text: string) => text, +})) +vi.mock('../api.ts', () => ({ + fetchTeamDashboardItems: vi.fn(), + fetchTeamDashboardWidgets: vi.fn(), + fetchTeamDashboardLayout: vi.fn(), + saveTeamDashboardLayout: vi.fn(), +})) + +enableAutoUnmount(afterEach) + +const widget: TeamDashboardWidget = { id: 'pages', title: 'Recent pages', order: 6, icon_url: '', icon_class: '', item_icons_round: false } + +function response(title: string): TeamDashboardResult { + return { + status: 'ok', + data: { items: [{ title, subtitle: '', link: '/page', iconUrl: '', overlayIconUrl: '', sinceId: '' }], emptyContentMessage: '', halfEmptyContentMessage: '' }, + } +} + +function setup() { + const member = ref(true) + getCircle.mockImplementation(() => ({ isMember: member.value })) + const wrapper = shallowMount(TeamOverviewView, { + props: { teamId: 'team-a' }, + global: { stubs: { + NcButton: { template: '' }, + NcEmptyContent: { props: ['name'], template: '
{{ name }}
' }, + TeamDashboardWidget: { + name: 'TeamDashboardWidget', + props: ['widget', 'result', 'loading'], + emits: ['retry'], + template: '
{{ JSON.stringify(result) }}
', + }, + TeamDashboardSettingsDialog: { + name: 'TeamDashboardSettingsDialog', + props: ['catalog', 'widgetIds', 'saving', 'saveLayout'], + template: '
', + }, + } }, + }) + async function settings() { + await wrapper.findAll('button').find((button) => button.text() === 'Customize dashboards')!.trigger('click') + return wrapper.getComponent({ name: 'TeamDashboardSettingsDialog' }) + } + return { wrapper, member, settings } +} + +async function refresh() { + document.dispatchEvent(new Event('visibilitychange')) + await flushPromises() +} + +describe('TeamOverviewView', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.spyOn(document, 'hidden', 'get').mockReturnValue(false) + vi.mocked(fetchTeamDashboardWidgets).mockReset().mockResolvedValue([widget]) + vi.mocked(fetchTeamDashboardItems).mockReset().mockResolvedValue(response('Page A')) + vi.mocked(fetchTeamDashboardLayout).mockReset().mockResolvedValue({ widgetIds: null, canEdit: true }) + vi.mocked(saveTeamDashboardLayout).mockReset() + }) + afterEach(() => { + vi.restoreAllMocks() + vi.useRealTimers() + }) + + it('loads team widgets and hides unavailable providers', async () => { + vi.mocked(fetchTeamDashboardItems).mockResolvedValue({ status: 'unavailable' }) + const { wrapper } = setup() + await flushPromises() + expect(fetchTeamDashboardItems).toHaveBeenCalledWith('team-a', 'pages', expect.any(AbortSignal)) + expect(wrapper.find('[data-widget]').exists()).toBe(false) + expect(wrapper.text()).toContain('No content yet') + }) + + it('loads only the explicit shared selection and hides editing for ordinary members', async () => { + vi.mocked(fetchTeamDashboardWidgets).mockResolvedValue([widget, { ...widget, id: 'deck-team-today' }]) + vi.mocked(fetchTeamDashboardLayout).mockResolvedValue({ widgetIds: ['pages'], canEdit: false }) + const { wrapper } = setup() + await flushPromises() + expect(fetchTeamDashboardItems).toHaveBeenCalledTimes(1) + expect(wrapper.findAll('[data-widget]').map((item) => item.attributes('data-widget'))).toEqual(['pages']) + expect(wrapper.text()).not.toContain('Customize dashboards') + }) + + it('issues no item requests for an explicitly empty selection but keeps customization available', async () => { + vi.mocked(fetchTeamDashboardLayout).mockResolvedValue({ widgetIds: [], canEdit: true }) + const { wrapper, settings } = setup() + await flushPromises() + expect(fetchTeamDashboardItems).not.toHaveBeenCalled() + expect(wrapper.text()).toContain('No dashboards selected') + expect((await settings()).props('catalog')).toEqual([widget]) + }) + + it('keeps newly discovered dashboards off after a selection is saved', async () => { + vi.mocked(fetchTeamDashboardLayout).mockResolvedValue({ widgetIds: ['pages'], canEdit: true }) + const { wrapper, settings } = setup() + await flushPromises() + vi.mocked(fetchTeamDashboardWidgets).mockResolvedValue([widget, { ...widget, id: 'deck-team-tomorrow' }]) + await refresh() + expect((await settings()).props('catalog')).toHaveLength(2) + expect(wrapper.findAll('[data-widget]')).toHaveLength(1) + expect(vi.mocked(fetchTeamDashboardItems).mock.calls.every((call) => call[1] === 'pages')).toBe(true) + }) + + it('saves an empty selection and stops refreshing hidden widgets', async () => { + const { wrapper, settings } = setup() + await flushPromises() + vi.mocked(saveTeamDashboardLayout).mockResolvedValue({ widgetIds: [], canEdit: true }) + vi.mocked(fetchTeamDashboardLayout).mockResolvedValue({ widgetIds: [], canEdit: true }) + expect(await (await settings()).props('saveLayout')([])).toBe(true) + await flushPromises() + expect(saveTeamDashboardLayout).toHaveBeenCalledWith('team-a', [], expect.any(AbortSignal)) + expect(wrapper.text()).toContain('No dashboards selected') + expect(fetchTeamDashboardItems).toHaveBeenCalledTimes(1) + }) + + it('keeps the confirmed selection when a save fails', async () => { + const { wrapper, settings } = setup() + await flushPromises() + const dialog = await settings() + vi.mocked(saveTeamDashboardLayout).mockRejectedValue(new Error('Save failed')) + await expect(dialog.props('saveLayout')([])).rejects.toThrow('Save failed') + await flushPromises() + expect(dialog.props('widgetIds')).toBeNull() + expect(dialog.props('saving')).toBe(false) + expect(wrapper.findAll('[data-widget]')).toHaveLength(1) + }) + + it('does not submit a save after editing permission is revoked', async () => { + const { wrapper, settings } = setup() + await flushPromises() + const save = (await settings()).props('saveLayout') + vi.mocked(fetchTeamDashboardLayout).mockResolvedValue({ widgetIds: null, canEdit: false }) + await refresh() + expect(wrapper.find('[data-test="settings"]').exists()).toBe(false) + expect(await save([])).toBe(false) + expect(saveTeamDashboardLayout).not.toHaveBeenCalled() + }) + + it('ignores a late save response and closes settings after changing teams', async () => { + let resolveSave!: (layout: TeamDashboardLayout) => void + vi.mocked(saveTeamDashboardLayout).mockImplementationOnce(() => new Promise((resolve) => { + resolveSave = resolve + })) + const { wrapper, settings } = setup() + await flushPromises() + const saved = (await settings()).props('saveLayout')([]) + await wrapper.setProps({ teamId: 'team-b' }) + await flushPromises() + resolveSave({ widgetIds: [], canEdit: true }) + expect(await saved).toBe(false) + expect(wrapper.find('[data-test="settings"]').exists()).toBe(false) + expect(wrapper.findAll('[data-widget]')).toHaveLength(1) + }) + + it('observes another admins shared selection on the next refresh', async () => { + const { wrapper } = setup() + await flushPromises() + vi.mocked(fetchTeamDashboardLayout).mockResolvedValue({ widgetIds: [], canEdit: true }) + await vi.advanceTimersByTimeAsync(33_000) + expect(wrapper.text()).toContain('No dashboards selected') + expect(fetchTeamDashboardItems).toHaveBeenCalledTimes(1) + }) + + it('retains selections across temporary provider disappearance', async () => { + vi.mocked(fetchTeamDashboardLayout).mockResolvedValue({ widgetIds: ['pages'], canEdit: true }) + vi.mocked(fetchTeamDashboardWidgets).mockResolvedValueOnce([]) + const { wrapper, settings } = setup() + await flushPromises() + expect((await settings()).props('widgetIds')).toEqual(['pages']) + expect(wrapper.find('[data-widget]').exists()).toBe(false) + await refresh() + expect(wrapper.findAll('[data-widget]')).toHaveLength(1) + }) + + it('does not fall back to all widgets when settings fail to load', async () => { + vi.mocked(fetchTeamDashboardLayout).mockRejectedValue(new Error('Unavailable')) + const { wrapper } = setup() + await flushPromises() + expect(wrapper.text()).toContain('Overview unavailable') + expect(fetchTeamDashboardItems).not.toHaveBeenCalled() + }) + + it('does not let a late team A result populate team B', async () => { + let resolveA!: (value: TeamDashboardResult) => void + vi.mocked(fetchTeamDashboardItems) + .mockImplementationOnce(() => new Promise((resolve) => { resolveA = resolve })) + .mockResolvedValue(response('Page B')) + const { wrapper } = setup() + await flushPromises() + const signalA = vi.mocked(fetchTeamDashboardItems).mock.calls[0]![2]! + await wrapper.setProps({ teamId: 'team-b' }) + await flushPromises() + expect(signalA.aborted).toBe(true) + resolveA(response('Page A')) + await flushPromises() + expect(wrapper.text()).toContain('Page B') + expect(wrapper.text()).not.toContain('Page A') + }) + + it('does not let late metadata from team A start requests in team B', async () => { + let resolveA!: (value: TeamDashboardWidget[]) => void + vi.mocked(fetchTeamDashboardWidgets).mockImplementationOnce(() => new Promise((resolve) => { + resolveA = resolve + })) + const { wrapper } = setup() + await wrapper.setProps({ teamId: 'team-b' }) + await flushPromises() + resolveA([{ ...widget, id: 'old-widget' }]) + await flushPromises() + expect(fetchTeamDashboardItems).toHaveBeenCalledTimes(1) + expect(fetchTeamDashboardItems).toHaveBeenCalledWith('team-b', 'pages', expect.any(AbortSignal)) + }) + + it('clears data immediately when membership is lost', async () => { + const { wrapper, member } = setup() + await flushPromises() + member.value = false + await flushPromises() + expect(wrapper.find('[data-widget]').exists()).toBe(false) + expect(wrapper.text()).not.toContain('Page A') + expect(fetchTeamDashboardWidgets).toHaveBeenCalledTimes(1) + }) + + it('clears all content when the server revokes access on refresh', async () => { + const { wrapper } = setup() + await flushPromises() + vi.mocked(fetchTeamDashboardItems).mockRejectedValue({ response: { status: 404 } }) + await refresh() + expect(wrapper.find('[data-widget]').exists()).toBe(false) + expect(wrapper.text()).toContain('Overview unavailable') + expect(wrapper.text()).not.toContain('Customize dashboards') + }) + + it('isolates provider errors and retries the affected widget', async () => { + vi.mocked(fetchTeamDashboardWidgets).mockResolvedValue([widget, { ...widget, id: 'broken' }]) + vi.mocked(fetchTeamDashboardItems).mockImplementation(async (_team, id) => id === 'broken' ? { status: 'error' } : response('Healthy')) + const { wrapper } = setup() + await flushPromises() + expect(wrapper.get('[data-widget="pages"]').text()).toContain('Healthy') + expect(wrapper.get('[data-widget="broken"]').text()).toContain('error') + vi.mocked(fetchTeamDashboardItems).mockResolvedValue(response('Recovered')) + await wrapper.get('[data-widget="broken"] button').trigger('click') + await flushPromises() + expect(wrapper.get('[data-widget="broken"]').text()).toContain('Recovered') + }) + + it('refreshes while active and cleans up on unmount', async () => { + const { wrapper } = setup() + await flushPromises() + await vi.advanceTimersByTimeAsync(33_000) + expect(fetchTeamDashboardItems).toHaveBeenCalledTimes(2) + const signal = vi.mocked(fetchTeamDashboardItems).mock.calls[1]![2]! + wrapper.unmount() + await vi.advanceTimersByTimeAsync(66_000) + document.dispatchEvent(new Event('visibilitychange')) + await flushPromises() + expect(signal.aborted).toBe(true) + expect(fetchTeamDashboardItems).toHaveBeenCalledTimes(2) + }) + + it('skips refresh while hidden', async () => { + vi.spyOn(document, 'hidden', 'get').mockReturnValue(true) + setup() + await flushPromises() + await vi.advanceTimersByTimeAsync(33_000) + expect(fetchTeamDashboardItems).toHaveBeenCalledTimes(1) + }) + + it('handles an unsupported endpoint and allows a full retry', async () => { + vi.mocked(fetchTeamDashboardWidgets).mockRejectedValueOnce({ response: { status: 404 } }) + const { wrapper } = setup() + await flushPromises() + expect(wrapper.text()).toContain('Overview unavailable') + expect(fetchTeamDashboardItems).not.toHaveBeenCalled() + await wrapper.findAll('button').find((button) => button.text() === 'Try again')!.trigger('click') + await flushPromises() + expect(wrapper.text()).toContain('Page A') + }) +}) diff --git a/src/teams/views/TeamOverviewView.vue b/src/teams/views/TeamOverviewView.vue new file mode 100644 index 000000000..6898733a6 --- /dev/null +++ b/src/teams/views/TeamOverviewView.vue @@ -0,0 +1,274 @@ + + + + + + + diff --git a/tests/unit/Controller/TeamDashboardSettingsControllerTest.php b/tests/unit/Controller/TeamDashboardSettingsControllerTest.php new file mode 100644 index 000000000..3a0cf4005 --- /dev/null +++ b/tests/unit/Controller/TeamDashboardSettingsControllerTest.php @@ -0,0 +1,100 @@ +circles = $this->createMock(CircleService::class); + $this->request = $this->createMock(CircleRequest::class); + $this->widgets = $this->createMock(IManager::class); + $this->permissions = $this->createMock(PermissionService::class); + $this->permissions->method('userMustBeMember')->willReturn(new Member()); + if (!$admin) { + $this->permissions->method('memberMustBeAtLeastAdmin')->willThrowException(new InsufficientPermissionException('Denied')); + } + $circle = new Circle(); + $circle->setSettings($stored === null ? [] : [TeamDashboardSettingsService::SETTING => $stored]); + $this->request->method('getCircle')->willReturn($circle); + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('alice'); + $session = $this->createMock(IUserSession::class); + $session->method('getUser')->willReturn($user); + $teams = $this->createMock(ITeamManager::class); + $teams->method('isMemberOfTeam')->with('team-a', 'alice')->willReturn($member); + return new TeamDashboardSettingsController('circles', $this->createMock(IRequest::class), $this->request, + $this->circles, $this->createMock(FederatedUserService::class), $this->permissions, + $session, $teams, $this->widgets, new TeamDashboardSettingsService()); + } + + public function testMembersReadSharedSelectionWithoutEditing(): void { + $controller = $this->controller(admin: false, stored: '["deck-team-today"]'); + self::assertSame(['widgetIds' => ['deck-team-today'], 'canEdit' => false], $controller->getLayout('team-a')->getData()); + } + + public function testNonMembersDoNotReadSettings(): void { + $controller = $this->controller(member: false); + $this->request->expects(self::never())->method('getCircle'); + $this->expectException(OCSNotFoundException::class); + $controller->getLayout('team-a'); + } + + public function testOrdinaryMemberCannotWrite(): void { + $controller = $this->controller(admin: false); + $this->circles->expects(self::never())->method('updateSetting'); + $this->widgets->expects(self::never())->method('getTeamWidgets'); + $this->expectException(OCSException::class); + $controller->setLayout('team-a', []); + } + + public function testAdminSavesExplicitEmptySelection(): void { + $controller = $this->controller(); + $this->widgets->method('getTeamWidgets')->willReturn([]); + $this->circles->expects(self::once())->method('updateSetting')->with('team-a', TeamDashboardSettingsService::SETTING, '[]'); + self::assertSame(['widgetIds' => [], 'canEdit' => true], $controller->setLayout('team-a', [])->getData()); + } + + public function testPreviouslySelectedDisabledWidgetCanBeRetained(): void { + $controller = $this->controller(stored: '["disabled-widget"]'); + $this->widgets->method('getTeamWidgets')->willReturn(['deck-team-today' => $this->createMock(ITeamWidget::class)]); + $this->circles->expects(self::once())->method('updateSetting')->with('team-a', TeamDashboardSettingsService::SETTING, '["disabled-widget","deck-team-today"]'); + $controller->setLayout('team-a', ['disabled-widget', 'deck-team-today']); + } + + public function testResetDoesNotRequireWidgetDiscovery(): void { + $controller = $this->controller(); + $this->widgets->expects(self::never())->method('getTeamWidgets'); + $this->circles->expects(self::once())->method('updateSetting')->with('team-a', TeamDashboardSettingsService::SETTING, 'null'); + self::assertNull($controller->setLayout('team-a', null)->getData()['widgetIds']); + } +} diff --git a/tests/unit/Service/TeamDashboardSettingsServiceTest.php b/tests/unit/Service/TeamDashboardSettingsServiceTest.php new file mode 100644 index 000000000..24233813f --- /dev/null +++ b/tests/unit/Service/TeamDashboardSettingsServiceTest.php @@ -0,0 +1,58 @@ +decode(null)); + self::assertNull($service->decode('null')); + self::assertSame([], $service->decode('[]')); + self::assertSame(['deck-team-today'], $service->validate(['deck-team-today', 'deck-team-today'], ['deck-team-today'])); + } + + /** @dataProvider invalidValues */ + public function testRejectsMalformedSelections(mixed $value): void { + $this->expectException(OCSBadRequestException::class); + (new TeamDashboardSettingsService())->validate($value); + } + + public static function invalidValues(): array { + return [ + ['deck-team-today'], + [['key' => 'deck-team-today']], + [[['nested']]], + [[42]], + [['']], + [[str_repeat('a', 65)]], + [array_fill(0, 101, 'deck-team-today')], + ]; + } + + public function testUnknownIdsAreRejected(): void { + $this->expectException(OCSBadRequestException::class); + (new TeamDashboardSettingsService())->validate(['personal-widget'], ['deck-team-today']); + } + + /** @dataProvider corruptSettings */ + public function testCorruptSettingsFailClosed(string $stored): void { + $this->expectException(OCSException::class); + (new TeamDashboardSettingsService())->decode($stored); + } + + public static function corruptSettings(): array { + return [['broken'], ['{}'], ['{"widget":"deck"}'], ['"deck"']]; + } +}