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
2 changes: 2 additions & 0 deletions appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
96 changes: 96 additions & 0 deletions lib/Controller/TeamDashboardSettingsController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Circles\Controller;

use OCA\Circles\Db\CircleRequest;
use OCA\Circles\Exceptions\CircleNotFoundException;
use OCA\Circles\Exceptions\InsufficientPermissionException;
use OCA\Circles\Model\Member;
use OCA\Circles\Service\CircleService;
use OCA\Circles\Service\FederatedUserService;
use OCA\Circles\Service\PermissionService;
use OCA\Circles\Service\TeamDashboardSettingsService;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\OCS\OCSException;
use OCP\AppFramework\OCS\OCSNotFoundException;
use OCP\AppFramework\OCSController;
use OCP\Dashboard\IManager;
use OCP\IRequest;
use OCP\IUserSession;
use OCP\Teams\ITeamManager;

class TeamDashboardSettingsController extends OCSController {
public function __construct(
string $appName,
IRequest $request,
private CircleRequest $circleRequest,
private CircleService $circleService,
private FederatedUserService $federatedUserService,
private PermissionService $permissionService,
private IUserSession $userSession,
private ITeamManager $teamManager,
private IManager $dashboardManager,
private TeamDashboardSettingsService $settings,
) {
parent::__construct($appName, $request);
}

private function authorize(string $circleId): Member {
$user = $this->userSession->getUser();
if ($user === null || !$this->teamManager->isMemberOfTeam($circleId, $user->getUID())) {

Check failure on line 48 in lib/Controller/TeamDashboardSettingsController.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis

UndefinedInterfaceMethod

lib/Controller/TeamDashboardSettingsController.php:48:46: UndefinedInterfaceMethod: Method OCP\Teams\ITeamManager::isMemberOfTeam does not exist (see https://psalm.dev/181)
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) ?? []);

Check failure on line 89 in lib/Controller/TeamDashboardSettingsController.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis

UndefinedInterfaceMethod

lib/Controller/TeamDashboardSettingsController.php:89:66: UndefinedInterfaceMethod: Method OCP\Dashboard\IManager::getTeamWidgets does not exist (see https://psalm.dev/181)
$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]);
}
}
47 changes: 47 additions & 0 deletions lib/Service/TeamDashboardSettingsService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Circles\Service;

use JsonException;
use OCP\AppFramework\OCS\OCSBadRequestException;
use OCP\AppFramework\OCS\OCSException;

class TeamDashboardSettingsService {
public const SETTING = 'teamsDashboardWidgetIds';

public function decode(?string $value): ?array {
if ($value === null) {
return null;
}
try {
return $this->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));
}
}
38 changes: 37 additions & 1 deletion src/teams/api.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
55 changes: 54 additions & 1 deletion src/teams/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -590,6 +590,59 @@ export async function fetchTeamResources(teamId: string): Promise<SharedResource
return res.data.ocs.data.resources ?? []
}

/**
*
* @param teamId - Team scope for widget discovery
* @param signal - Cancels requests when the view changes
*/
export async function fetchTeamDashboardWidgets(teamId: string, signal?: AbortSignal): Promise<TeamDashboardWidget[]> {
const response = await axios.get<OcsResponse<Record<string, TeamDashboardWidget>>>(
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<TeamDashboardResult> {
const response = await axios.get<OcsResponse<Record<string, TeamDashboardResult>>>(
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<TeamDashboardLayout> {
const response = await axios.get<OcsResponse<TeamDashboardLayout>>(
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<TeamDashboardLayout> {
const response = await axios.put<OcsResponse<TeamDashboardLayout>>(
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.
Expand Down
83 changes: 83 additions & 0 deletions src/teams/components/TeamDashboardSettingsDialog.spec.ts
Original file line number Diff line number Diff line change
@@ -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<typeof L10n>(),
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: '<div><slot /><slot name="actions" /></div>' },
NcCheckboxRadioSwitch: {
props: ['modelValue', 'disabled'],
emits: ['update:modelValue'],
template: '<label><input type="checkbox" :checked="modelValue" :disabled="disabled" @change="$emit(\'update:modelValue\', $event.target.checked)"><slot /></label>',
},
NcButton: { template: '<button><slot /></button>' },
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()
})
})
Loading
Loading