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
6 changes: 5 additions & 1 deletion lib/AppInfo/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
use OCA\Circles\Events\EditingCircleEvent;
use OCA\Collectives\CacheListener;
use OCA\Collectives\ConfigLexicon;
use OCA\Collectives\Dashboard\RecentPagesTeamWidget;
use OCA\Collectives\Dashboard\RecentPagesWidget;
use OCA\Collectives\Db\CollectiveMapper;
use OCA\Collectives\Db\CollectiveVersionMapper;
Expand Down Expand Up @@ -51,6 +52,7 @@
use OCP\AppFramework\Http\Events\BeforeTemplateRenderedEvent;
use OCP\Collaboration\Reference\RenderReferenceEvent;
use OCP\Dashboard\IAPIWidgetV2;
use OCP\Dashboard\ITeamWidget;
use OCP\Files\Config\IMountProviderCollection;
use OCP\Files\Events\Node\NodeRenamedEvent;
use OCP\Files\Events\Node\NodeWrittenEvent;
Expand Down Expand Up @@ -141,7 +143,9 @@ public function register(IRegistrationContext $context): void {
$cacheListener = $this->getContainer()->get(CacheListener::class);
$cacheListener->listen();

if (interface_exists(IAPIWidgetV2::class)) {
if (interface_exists(ITeamWidget::class)) {
$context->registerDashboardWidget(RecentPagesTeamWidget::class);
} elseif (interface_exists(IAPIWidgetV2::class)) {
$context->registerDashboardWidget(RecentPagesWidget::class);
}

Expand Down
49 changes: 49 additions & 0 deletions lib/Dashboard/RecentPagesTeamWidget.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

declare(strict_types=1);

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

namespace OCA\Collectives\Dashboard;

use OCA\Collectives\Db\Collective;
use OCP\Dashboard\ITeamWidget;
use OCP\Dashboard\Model\WidgetItems;

class RecentPagesTeamWidget extends RecentPagesWidget implements ITeamWidget {
/** @var array<string, list<Collective>> */
private array $teamCollectives = [];

public function isEnabledForTeam(string $teamId, string $userId): bool {
return $this->getTeamCollectives($teamId, $userId) !== [];
}

public function getTeamUrl(string $teamId, string $userId): ?string {
$collective = $this->getTeamCollectives($teamId, $userId)[0] ?? null;
if ($collective === null) {
return null;
}

$path = $collective->getSlug()
? $collective->getSlug() . '-' . $collective->getId()
: $collective->getName();

return $this->urlGenerator->linkToRoute('collectives.start.indexPath', ['path' => $path]);
}

public function getTeamItems(string $teamId, string $userId, ?string $since = null, int $limit = 7): WidgetItems {
return $this->formatItems(
$this->recentPagesService->forTeam($teamId, $userId, $limit),
$this->l10n->t('No recent pages in this collective'),
);
}

/** @return list<Collective> */
private function getTeamCollectives(string $teamId, string $userId): array {
$key = "$teamId:$userId";
return $this->teamCollectives[$key] ??= $this->recentPagesService->getTeamCollectives($teamId, $userId);
}
}
10 changes: 9 additions & 1 deletion lib/Dashboard/RecentPagesWidget.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

use OCA\Collectives\Model\RecentPage;
use OCA\Collectives\Service\RecentPagesService;
use OCP\Dashboard\IIconWidget;
use OCP\Dashboard\IReloadableWidget;
Expand Down Expand Up @@ -34,6 +35,13 @@ public function getItemsV2(string $userId, ?string $since = null, int $limit = 7

$recentPages = $this->recentPagesService->forUser($user, $limit);

return $this->formatItems($recentPages, $this->l10n->t('Add a collective'));
}

/**
* @param list<RecentPage> $recentPages
*/
protected function formatItems(array $recentPages, string $emptyMessage): WidgetItems {
$items = [];
foreach ($recentPages as $recentPage) {
$subtitle = $recentPage->getPagePath()
Expand All @@ -48,7 +56,7 @@ public function getItemsV2(string $userId, ?string $since = null, int $limit = 7
);
}

return new WidgetItems($items, $this->l10n->t('Add a collective'));
return new WidgetItems($items, $emptyMessage);
}

protected function getFallbackDataIcon(): string {
Expand Down
33 changes: 29 additions & 4 deletions lib/Service/RecentPagesService.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

namespace OCA\Collectives\Service;

use OCA\Collectives\Db\Collective;
use OCA\Collectives\Model\PageInfo;
use OCA\Collectives\Model\RecentPage;
use OCP\DB\Exception;
Expand Down Expand Up @@ -42,10 +43,32 @@ public function __construct(
* @throws Exception
*/
public function forUser(IUser $user, int $limit = 10): array {
return $this->getRecentPages($user->getUID(), $limit);
}

/** @return list<Collective> */
public function getTeamCollectives(string $teamId, string $userId): array {
try {
$collectives = $this->collectiveService->getCollectives($userId);
} catch (NotFoundException|NotPermittedException) {
return [];
}

return array_values(array_filter($collectives, static fn (Collective $collective): bool => $collective->getCircleId() === $teamId));
}

/** @return list<RecentPage> */
public function forTeam(string $teamId, string $userId, int $limit = 7): array {
return $this->getRecentPages($userId, $limit, $teamId);
}

/** @return list<RecentPage> */
private function getRecentPages(string $userId, int $limit, ?string $teamId = null): array {
$queryData = $this->getRecentPagesQuery(
$user->getUID(),
$userId,
$limit,
['p.*', 'f.mtime as timestamp', 'f.name as filename', 'f.path as path']
['p.*', 'f.mtime as timestamp', 'f.name as filename', 'f.path as path'],
teamId: $teamId,
);

if ($queryData === null) {
Expand Down Expand Up @@ -204,9 +227,11 @@ public function forUserAsPageInfo(string $userId, ?string $query, int $limit = 1
* @return array{result: \OCP\DB\IResult, collectivesMap: array, appData: string}|null
* @throws Exception
*/
private function getRecentPagesQuery(string $userId, int $limit, array $selectFields, ?string $query = null): ?array {
private function getRecentPagesQuery(string $userId, int $limit, array $selectFields, ?string $query = null, ?string $teamId = null): ?array {
try {
$collectives = $this->collectiveService->getCollectives($userId);
$collectives = $teamId === null
? $this->collectiveService->getCollectives($userId)
: $this->getTeamCollectives($teamId, $userId);
} catch (NotFoundException|NotPermittedException) {
return null;
}
Expand Down
179 changes: 179 additions & 0 deletions tests/Unit/Dashboard/RecentPagesTeamWidgetTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
<?php

declare(strict_types=1);

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

namespace Unit\Dashboard;

use OCA\Collectives\AppInfo\Application;
use OCA\Collectives\CacheListener;
use OCA\Collectives\Dashboard\RecentPagesTeamWidget;
use OCA\Collectives\Dashboard\RecentPagesWidget;
use OCA\Collectives\Db\Collective;
use OCA\Collectives\Model\RecentPage;
use OCA\Collectives\Service\RecentPagesService;
use OCP\AppFramework\Bootstrap\IRegistrationContext;
use OCP\Dashboard\ITeamWidget;
use OCP\IL10N;
use OCP\IURLGenerator;
use OCP\IUser;
use OCP\IUserSession;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Container\ContainerInterface;

class RecentPagesTeamWidgetTest extends TestCase {
private RecentPagesService&MockObject $service;
private IURLGenerator&MockObject $urls;
private IUserSession&MockObject $userSession;
private IL10N&MockObject $l10n;
private RecentPagesTeamWidget $widget;

protected function setUp(): void {
parent::setUp();
if (!interface_exists(ITeamWidget::class)) {
self::markTestSkipped('Team widgets require Nextcloud 36.');
}

$this->service = $this->createMock(RecentPagesService::class);
$this->urls = $this->createMock(IURLGenerator::class);
$this->userSession = $this->createMock(IUserSession::class);
$this->l10n = $this->createMock(IL10N::class);
$this->l10n->method('t')->willReturnArgument(0);
$this->widget = new RecentPagesTeamWidget($this->l10n, $this->urls, $this->userSession, $this->service);
}

public function testSharesPersonalWidgetMetadata(): void {
$personal = new RecentPagesWidget($this->l10n, $this->urls, $this->userSession, $this->service);
self::assertSame('collectives-recent-pages', $this->widget->getId());
self::assertSame($personal->getId(), $this->widget->getId());
self::assertSame($personal->getTitle(), $this->widget->getTitle());
self::assertSame($personal->getOrder(), $this->widget->getOrder());
self::assertSame($personal->getReloadInterval(), $this->widget->getReloadInterval());
self::assertSame($personal->getIconClass(), $this->widget->getIconClass());
}

public function testRegistersOnlyTheTeamCapableWidget(): void {
$listener = $this->createMock(CacheListener::class);
$listener->expects(self::once())->method('listen');
$container = $this->createMock(ContainerInterface::class);
$container->method('get')->with(CacheListener::class)->willReturn($listener);
$application = $this->getMockBuilder(Application::class)
->disableOriginalConstructor()->onlyMethods(['getContainer'])->getMock();
$application->method('getContainer')->willReturn($container);
$context = $this->createMock(IRegistrationContext::class);
$context->expects(self::once())->method('registerDashboardWidget')->with(RecentPagesTeamWidget::class);

$application->register($context);
}

public function testEnabledAndUrlShareCollectiveLookup(): void {
$collective = new Collective();
$collective->setId(42);
$collective->setSlug('team-handbook');
$collective->setName('Team handbook');
$this->service->expects(self::once())->method('getTeamCollectives')
->with('team-a', 'alice')->willReturn([$collective, new Collective()]);
$this->service->expects(self::never())->method('forTeam');
$this->urls->expects(self::once())->method('linkToRoute')
->with('collectives.start.indexPath', ['path' => 'team-handbook-42'])
->willReturn('/collectives/team-handbook-42');

self::assertTrue($this->widget->isEnabledForTeam('team-a', 'alice'));
self::assertSame('/collectives/team-handbook-42', $this->widget->getTeamUrl('team-a', 'alice'));
}

public function testUrlFallsBackToCollectiveName(): void {
$collective = new Collective();
$collective->setName('Team handbook');
$this->service->method('getTeamCollectives')->willReturn([$collective]);
$this->urls->expects(self::once())->method('linkToRoute')
->with('collectives.start.indexPath', ['path' => 'Team handbook'])
->willReturn('/collectives/Team%20handbook');

self::assertSame('/collectives/Team%20handbook', $this->widget->getTeamUrl('team-a', 'alice'));
}

public function testNoCollectiveDisablesWidgetAndCachesEmptyLookup(): void {
$this->service->expects(self::once())->method('getTeamCollectives')
->with('team-a', 'alice')->willReturn([]);
$this->service->expects(self::never())->method('forTeam');
$this->urls->expects(self::never())->method('linkToRoute');

self::assertFalse($this->widget->isEnabledForTeam('team-a', 'alice'));
self::assertNull($this->widget->getTeamUrl('team-a', 'alice'));
}

public function testCollectiveCacheIsScopedToTeamAndUser(): void {
$this->service->expects(self::exactly(3))->method('getTeamCollectives')
->willReturnCallback(static fn (string $teamId, string $userId): array
=> $teamId === 'team-a' && $userId === 'alice' ? [new Collective()] : []);

self::assertTrue($this->widget->isEnabledForTeam('team-a', 'alice'));
self::assertFalse($this->widget->isEnabledForTeam('team-b', 'alice'));
self::assertFalse($this->widget->isEnabledForTeam('team-a', 'bob'));
self::assertTrue($this->widget->isEnabledForTeam('team-a', 'alice'));
}

public function testTeamItemsUseSharedFormattingAndExplicitActor(): void {
$page = new RecentPage();
$page->setTitle('Handbook')->setCollectiveNameWithEmoji('Team')
->setPagePath('Guides')->setPageUrl('/collectives/handbook')
->setEmoji('A')->setTimestamp(123);
$this->service->expects(self::once())->method('forTeam')
->with('team-a', 'alice', 3)->willReturn([$page]);
$this->service->expects(self::never())->method('forUser');
$this->userSession->expects(self::never())->method('getUser');

$result = $this->widget->getTeamItems('team-a', 'alice', null, 3);
self::assertSame('No recent pages in this collective', $result->getEmptyContentMessage());
self::assertCount(1, $result->getItems());
$item = $result->getItems()[0];
self::assertSame('Handbook', $item->getTitle());
self::assertSame('Team - Guides', $item->getSubtitle());
self::assertSame('/collectives/handbook', $item->getLink());
self::assertSame('123', $item->getSinceId());
self::assertStringStartsWith('data:image/svg+xml;base64,', $item->getIconUrl());
self::assertStringContainsString('>A</text>', base64_decode(substr($item->getIconUrl(), 26)));
}

public function testTeamEmptyMessageAndDefaultLimit(): void {
$this->service->expects(self::once())->method('forTeam')
->with('team-a', 'alice', 7)->willReturn([]);

$result = $this->widget->getTeamItems('team-a', 'alice');
self::assertSame([], $result->getItems());
self::assertSame('No recent pages in this collective', $result->getEmptyContentMessage());
}

public function testPersonalItemsKeepSessionUserAndFormatting(): void {
$user = $this->createMock(IUser::class);
$this->userSession->method('getUser')->willReturn($user);
$page = new RecentPage();
$page->setTitle('Landing page')->setCollectiveNameWithEmoji('Team')
->setPagePath('')->setPageUrl('/collectives/team')->setTimestamp(123);
$this->service->expects(self::once())->method('forUser')->with($user, 3)->willReturn([$page]);
$this->service->expects(self::never())->method('forTeam');

$result = $this->widget->getItemsV2('alice', null, 3);
self::assertSame('Add a collective', $result->getEmptyContentMessage());
self::assertCount(1, $result->getItems());
self::assertSame('Team', $result->getItems()[0]->getSubtitle());
self::assertSame('Landing page', $result->getItems()[0]->getTitle());
self::assertSame('/collectives/team', $result->getItems()[0]->getLink());
self::assertStringStartsWith('data:image/svg+xml;base64,', $result->getItems()[0]->getIconUrl());
}

public function testPersonalItemsWithoutSessionUser(): void {
$this->userSession->method('getUser')->willReturn(null);
$this->service->expects(self::never())->method('forUser');

$result = $this->widget->getItemsV2('alice');
self::assertSame([], $result->getItems());
self::assertSame('', $result->getEmptyContentMessage());
}
}
Loading
Loading