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
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,3 +155,15 @@ like `git config --global alias.ci 'commit -s'`. Now you can commit with

[dcofile]: https://github.com/nextcloud/server/blob/master/contribute/developer-certificate-of-origin
[applyalicense]: https://github.com/nextcloud/server/blob/master/contribute/HowToApplyALicense.md

## Teams navigation integration

Deck implements `OCP\Teams\ITeamAppProvider` and declares it through
`IRegistrationContext::registerTeamAppProvider()`, without a registration event.
Team-owned boards appear as external links,
while boards personally shared to a team remain in its shared-resource list.
The provider is PHP-only: it discovers active team boards, returns external links
and a declarative creation action, and creates boards through Deck's existing
service. It returns no browser module. Upgrade Deck and Circles together;
older Deck versions no longer receive a hard-coded integration from Circles.
Focused coverage: `tests/unit/Teams/TeamsProviderTest.php`.
1 change: 1 addition & 0 deletions appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@
],
'ocs' => [
['name' => 'board_ocs#index', 'url' => '/api/v{apiVersion}/boards', 'verb' => 'GET'],
['name' => 'board_ocs#listForTeam', 'url' => '/api/v{apiVersion}/boards/team/{teamId}', 'verb' => 'GET'],
['name' => 'board_ocs#read', 'url' => '/api/v{apiVersion}/board/{boardId}', 'verb' => 'GET'],
['name' => 'stack_ocs#index', 'url' => '/api/v{apiVersion}/stacks/{boardId}', 'verb' => 'GET'],
['name' => 'board_ocs#create', 'url' => '/api/v{apiVersion}/boards', 'verb' => 'POST'],
Expand Down
3 changes: 3 additions & 0 deletions lib/AppInfo/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,9 @@
}

public function register(IRegistrationContext $context): void {
if (interface_exists(\OCP\Teams\ITeamAppProvider::class)) {
$context->registerTeamAppProvider(\OCA\Deck\Teams\TeamsProvider::class);

Check failure on line 131 in lib/AppInfo/Application.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis

MissingDependency

lib/AppInfo/Application.php:131:38: MissingDependency: OCA\Deck\Teams\TeamsProvider depends on class or interface ocp\teams\iteamappprovider that does not exist (see https://psalm.dev/157)

Check failure on line 131 in lib/AppInfo/Application.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis

UndefinedInterfaceMethod

lib/AppInfo/Application.php:131:14: UndefinedInterfaceMethod: Method OCP\AppFramework\Bootstrap\IRegistrationContext::registerTeamAppProvider does not exist (see https://psalm.dev/181)
}
if ((@include_once __DIR__ . '/../../vendor/autoload.php') === false) {
throw new Exception('Cannot include autoload. Did you run install dependencies using composer?');
}
Expand Down
20 changes: 20 additions & 0 deletions lib/Controller/BoardOcsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,26 @@

namespace OCA\Deck\Controller;

use OCA\Deck\Db\BoardMapper;
use OCA\Deck\Service\BoardService;
use OCA\Deck\Service\ExternalBoardService;
use OCP\AppFramework\OCS\OCSNotFoundException;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\Attribute\PublicPage;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\OCSController;
use OCP\IRequest;
use OCP\Teams\ITeamManager;
use Psr\Log\LoggerInterface;

class BoardOcsController extends OCSController {
public function __construct(
string $appName,
IRequest $request,
private BoardService $boardService,
private BoardMapper $boardMapper,
private ExternalBoardService $externalBoardService,
private ITeamManager $teamManager,
private LoggerInterface $logger,
private $userId,
) {
Expand All @@ -34,6 +39,21 @@
return new DataResponse($internalBoards);
}

#[NoAdminRequired]
public function listForTeam(string $teamId): DataResponse {
if ($this->userId === null || !$this->teamManager->isMemberOfTeam($teamId, $this->userId)) {

Check failure on line 44 in lib/Controller/BoardOcsController.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis

UndefinedInterfaceMethod

lib/Controller/BoardOcsController.php:44:54: UndefinedInterfaceMethod: Method OCP\Teams\ITeamManager::isMemberOfTeam does not exist (see https://psalm.dev/181)
throw new OCSNotFoundException('Team not found');
}
$boards = array_map(static fn ($board) => [
'id' => $board->getId(),
'title' => $board->getTitle(),
'teamId' => $board->getTeamId(),
'archived' => $board->getArchived(),
'deletedAt' => $board->getDeletedAt(),
], $this->boardMapper->findAllAttachedToTeam($teamId));
return new DataResponse($boards);
}

#[NoAdminRequired]
#[PublicPage]
public function read(int $boardId): DataResponse {
Expand Down
89 changes: 89 additions & 0 deletions lib/Teams/TeamsProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
<?php

declare(strict_types=1);

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

namespace OCA\Deck\Teams;

use InvalidArgumentException;
use OCA\Deck\Db\BoardMapper;
use OCA\Deck\Service\BoardService;
use OCP\IL10N;
use OCP\Teams\ITeamAppProvider;
use OCP\Teams\TeamAppAction;
use OCP\Teams\TeamAppActionPrompt;
use OCP\Teams\TeamAppActionResult;
use OCP\Teams\TeamAppEntry;
use OCP\Teams\TeamAppNavigation;
use OCP\Teams\TeamAppResourceKey;
use OCP\Teams\TeamAppRouteAlias;

class TeamsProvider implements ITeamAppProvider {

Check failure on line 25 in lib/Teams/TeamsProvider.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis

UndefinedClass

lib/Teams/TeamsProvider.php:25:32: UndefinedClass: Class, interface or enum named OCP\Teams\ITeamAppProvider does not exist (see https://psalm.dev/019)
public function __construct(
private BoardService $boards,
private BoardMapper $boardMapper,
private IL10N $l10n,
) {
}

public function getId(): string {
return 'deck';
}

public function load(): ?string {
return null;
}

public function getOrder(): int {
return 20;
}

/** @return list<TeamAppRouteAlias> */
public function getRouteAliases(): array {
return [];
}

public function getNavigation(array $context): TeamAppNavigation {
$boards = [];
foreach ($this->boardMapper->findAllAttachedToTeam($context['teamId']) as $board) {
if (!$board->getArchived() && !$board->getDeletedAt()) {
$boards[(string)$board->getId()] = $board;
}
}
$entries = [];
$keys = [];
foreach ($context['resources'] as $resource) {
if ($resource->getProvider()->getId() !== 'deck' || !isset($boards[$resource->getId()])) {
continue;
}
$board = $boards[$resource->getId()];
$entries[] = new TeamAppEntry(
id: (string)$board->getId(), label: $board->getTitle(),
href: $resource->getUrl(), iconSvg: $resource->getIconSvg(),
legacyIds: ['board-' . $board->getId()],
);
$keys[] = new TeamAppResourceKey('deck', $resource->getId());
}
usort($entries, static fn (TeamAppEntry $left, TeamAppEntry $right): int => strnatcasecmp($left->label, $right->label));
return new TeamAppNavigation(entries: $entries, resourceKeys: $keys, actions: $context['isAdmin'] ? [new TeamAppAction(
id: 'deck-create', label: $this->l10n->t('New Deck board'),
prompt: new TeamAppActionPrompt($this->l10n->t('Board name')),
)] : []);
}

public function executeAction(array $context, string $actionId, array $parameters): TeamAppActionResult {
if (!$context['isAdmin'] || $actionId !== 'deck-create') {
throw new InvalidArgumentException('Unknown or unauthorized Teams action');
}
$title = $parameters['value'] ?? null;
if (!is_string($title) || trim($title) === '') {
throw new InvalidArgumentException('Board name must not be empty');
}
$this->boards->createForTeam(trim($title), $context['userId'], null, $context['teamId']);
return new TeamAppActionResult();
}
}
80 changes: 80 additions & 0 deletions tests/unit/Teams/TeamsProviderTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
<?php

declare(strict_types=1);

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

namespace OCA\Deck\Tests\Teams;

use OCA\Deck\Db\Board;
use OCA\Deck\Db\BoardMapper;
use OCA\Deck\Service\BoardService;
use OCA\Deck\Teams\TeamsProvider;
use OCP\IL10N;
use OCP\Teams\ITeamAppProvider;
use OCP\Teams\ITeamResourceProvider;
use OCP\Teams\TeamAppActionResult;
use OCP\Teams\TeamAppResourceKey;
use OCP\Teams\TeamResource;
use PHPUnit\Framework\TestCase;

final class TeamsProviderTest extends TestCase {
public function testPhpDiscoversOnlyActiveTeamBoardsAndTheirResourceKeys(): void {
$service = $this->createMock(BoardService::class);
$boardMapper = $this->createMock(BoardMapper::class);
$boards = [];
foreach ([[1, false, 0], [2, true, 0], [3, false, 123]] as [$id, $archived, $deleted]) {
$board = new Board();
$board->setId($id);
$board->setTitle('Board ' . $id);
$board->setTeamId('team');
$board->setArchived($archived);
$board->setDeletedAt($deleted);
$boards[] = $board;
}
$boardMapper->method('findAllAttachedToTeam')->with('team')->willReturn($boards);
$resources = $this->createMock(ITeamResourceProvider::class);
$resources->method('getId')->willReturn('deck');
$provider = new TeamsProvider($service, $boardMapper, $this->createMock(IL10N::class));
$result = $provider->getNavigation(['teamId' => 'team', 'isAdmin' => false, 'resources' => array_map(static fn (int $id): TeamResource => new TeamResource($resources, (string)$id, 'Board', '/deck/' . $id), [1, 2, 3])]);
$this->assertSame(['1'], array_column($result->entries, 'id'));
$this->assertSame('/deck/1', $result->entries[0]->href);
$this->assertEquals([new TeamAppResourceKey('deck', '1')], $result->resourceKeys);
$this->assertSame([], $result->actions);
}

public function testCreationUsesTheAuthorizedTeamAndUser(): void {
$service = $this->createMock(BoardService::class);
$service->expects($this->once())->method('createForTeam')->with('New', 'member', null, 'team')->willReturn(new Board());
$provider = new TeamsProvider($service, $this->createMock(BoardMapper::class), $this->createMock(IL10N::class));
$this->assertEquals(new TeamAppActionResult(), $provider->executeAction(['isAdmin' => true, 'userId' => 'member', 'teamId' => 'team'], 'deck-create', ['value' => ' New ']));
}

public function testNonadminCannotCreateBoards(): void {
$service = $this->createMock(BoardService::class);
$service->expects($this->never())->method('createForTeam');
$this->expectException(\InvalidArgumentException::class);
(new TeamsProvider($service, $this->createMock(BoardMapper::class), $this->createMock(IL10N::class)))->executeAction(['isAdmin' => false], 'deck-create', ['value' => 'New']);
}

public function testCreationRejectsEmptyNames(): void {
$service = $this->createMock(BoardService::class);
$service->expects($this->never())->method('createForTeam');
$this->expectException(\InvalidArgumentException::class);
(new TeamsProvider($service, $this->createMock(BoardMapper::class), $this->createMock(IL10N::class)))->executeAction(['isAdmin' => true], 'deck-create', ['value' => ' ']);
}

public function testImplementsCoreProviderAndLoadsItsModule(): void {
if (!interface_exists(ITeamAppProvider::class)) {
$this->markTestSkipped('Requires the core Teams app provider API');
}
$provider = new TeamsProvider($this->createMock(BoardService::class), $this->createMock(BoardMapper::class), $this->createMock(IL10N::class));
$this->assertInstanceOf(ITeamAppProvider::class, $provider);
$this->assertSame('deck', $provider->getId());
$this->assertNull($provider->load());
$this->assertSame(20, $provider->getOrder());
}
}
Loading