diff --git a/core/src/types/teams.d.ts b/core/src/types/teams.d.ts new file mode 100644 index 0000000000000..55a8d3f18fe22 --- /dev/null +++ b/core/src/types/teams.d.ts @@ -0,0 +1,76 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +export interface TeamFolder { + id: number + mountPoint: string + quota: number | null +} + +export interface SharedResourceProvider { + id: string + name: string +} + +export interface SharedResource { + id: string | number + label: string + url: string + iconSvg?: string + iconURL?: string + iconEmoji?: string + provider: SharedResourceProvider +} + +export interface TeamAppAction { + id: string + label: string + icon?: string + prompt?: { title: string, value?: string } + confirm?: { title: string, message: string } + resourcePicker?: TeamAppResourcePickerOptions +} + +export interface TeamAppEntry { + id: string + label: string + icon?: string + iconSvg?: string + iconEmoji?: string + href?: string + legacyIds?: string[] + actions?: TeamAppAction[] + viewData?: Record +} + +export interface TeamAppNavigation { + entries: TeamAppEntry[] + actions?: TeamAppAction[] + resourceKeys?: Array<{ providerId: string, resourceId: string }> +} + +export interface TeamAppActionResult { + navigationEntryIds?: string[] + open?: string +} + +export interface TeamAppResource extends Pick { + description?: string +} + +export interface TeamAppResourcePickerOptions { + title: string + resources: TeamAppResource[] + selectedIds?: string[] +} + +export interface TeamAppContext { + teamId: string + signal: AbortSignal +} + +export interface TeamAppView { + mount: (element: HTMLElement, context: TeamAppContext, entry: TeamAppEntry) => Promise<() => void> +} diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index b3b5b63fb3a1e..50fb602e9bab9 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -1074,10 +1074,21 @@ 'OCP\\TaskProcessing\\TaskTypes\\TextToTextSummary' => $baseDir . '/lib/public/TaskProcessing/TaskTypes/TextToTextSummary.php', 'OCP\\TaskProcessing\\TaskTypes\\TextToTextTopics' => $baseDir . '/lib/public/TaskProcessing/TaskTypes/TextToTextTopics.php', 'OCP\\TaskProcessing\\TaskTypes\\TextToTextTranslate' => $baseDir . '/lib/public/TaskProcessing/TaskTypes/TextToTextTranslate.php', + 'OCP\\Teams\\ITeamAppProvider' => $baseDir . '/lib/public/Teams/ITeamAppProvider.php', 'OCP\\Teams\\ITeamFolderProvider' => $baseDir . '/lib/public/Teams/ITeamFolderProvider.php', 'OCP\\Teams\\ITeamManager' => $baseDir . '/lib/public/Teams/ITeamManager.php', 'OCP\\Teams\\ITeamResourceProvider' => $baseDir . '/lib/public/Teams/ITeamResourceProvider.php', 'OCP\\Teams\\Team' => $baseDir . '/lib/public/Teams/Team.php', + 'OCP\\Teams\\TeamAppAction' => $baseDir . '/lib/public/Teams/TeamAppAction.php', + 'OCP\\Teams\\TeamAppActionConfirmation' => $baseDir . '/lib/public/Teams/TeamAppActionConfirmation.php', + 'OCP\\Teams\\TeamAppActionPrompt' => $baseDir . '/lib/public/Teams/TeamAppActionPrompt.php', + 'OCP\\Teams\\TeamAppActionResult' => $baseDir . '/lib/public/Teams/TeamAppActionResult.php', + 'OCP\\Teams\\TeamAppEntry' => $baseDir . '/lib/public/Teams/TeamAppEntry.php', + 'OCP\\Teams\\TeamAppNavigation' => $baseDir . '/lib/public/Teams/TeamAppNavigation.php', + 'OCP\\Teams\\TeamAppResource' => $baseDir . '/lib/public/Teams/TeamAppResource.php', + 'OCP\\Teams\\TeamAppResourceKey' => $baseDir . '/lib/public/Teams/TeamAppResourceKey.php', + 'OCP\\Teams\\TeamAppResourcePicker' => $baseDir . '/lib/public/Teams/TeamAppResourcePicker.php', + 'OCP\\Teams\\TeamAppRouteAlias' => $baseDir . '/lib/public/Teams/TeamAppRouteAlias.php', 'OCP\\Teams\\TeamFolder' => $baseDir . '/lib/public/Teams/TeamFolder.php', 'OCP\\Teams\\TeamResource' => $baseDir . '/lib/public/Teams/TeamResource.php', 'OCP\\Template' => $baseDir . '/lib/public/Template.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index 35c872fd7527d..cde62bc899be4 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -1115,10 +1115,21 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OCP\\TaskProcessing\\TaskTypes\\TextToTextSummary' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/TaskTypes/TextToTextSummary.php', 'OCP\\TaskProcessing\\TaskTypes\\TextToTextTopics' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/TaskTypes/TextToTextTopics.php', 'OCP\\TaskProcessing\\TaskTypes\\TextToTextTranslate' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/TaskTypes/TextToTextTranslate.php', + 'OCP\\Teams\\ITeamAppProvider' => __DIR__ . '/../../..' . '/lib/public/Teams/ITeamAppProvider.php', 'OCP\\Teams\\ITeamFolderProvider' => __DIR__ . '/../../..' . '/lib/public/Teams/ITeamFolderProvider.php', 'OCP\\Teams\\ITeamManager' => __DIR__ . '/../../..' . '/lib/public/Teams/ITeamManager.php', 'OCP\\Teams\\ITeamResourceProvider' => __DIR__ . '/../../..' . '/lib/public/Teams/ITeamResourceProvider.php', 'OCP\\Teams\\Team' => __DIR__ . '/../../..' . '/lib/public/Teams/Team.php', + 'OCP\\Teams\\TeamAppAction' => __DIR__ . '/../../..' . '/lib/public/Teams/TeamAppAction.php', + 'OCP\\Teams\\TeamAppActionConfirmation' => __DIR__ . '/../../..' . '/lib/public/Teams/TeamAppActionConfirmation.php', + 'OCP\\Teams\\TeamAppActionPrompt' => __DIR__ . '/../../..' . '/lib/public/Teams/TeamAppActionPrompt.php', + 'OCP\\Teams\\TeamAppActionResult' => __DIR__ . '/../../..' . '/lib/public/Teams/TeamAppActionResult.php', + 'OCP\\Teams\\TeamAppEntry' => __DIR__ . '/../../..' . '/lib/public/Teams/TeamAppEntry.php', + 'OCP\\Teams\\TeamAppNavigation' => __DIR__ . '/../../..' . '/lib/public/Teams/TeamAppNavigation.php', + 'OCP\\Teams\\TeamAppResource' => __DIR__ . '/../../..' . '/lib/public/Teams/TeamAppResource.php', + 'OCP\\Teams\\TeamAppResourceKey' => __DIR__ . '/../../..' . '/lib/public/Teams/TeamAppResourceKey.php', + 'OCP\\Teams\\TeamAppResourcePicker' => __DIR__ . '/../../..' . '/lib/public/Teams/TeamAppResourcePicker.php', + 'OCP\\Teams\\TeamAppRouteAlias' => __DIR__ . '/../../..' . '/lib/public/Teams/TeamAppRouteAlias.php', 'OCP\\Teams\\TeamFolder' => __DIR__ . '/../../..' . '/lib/public/Teams/TeamFolder.php', 'OCP\\Teams\\TeamResource' => __DIR__ . '/../../..' . '/lib/public/Teams/TeamResource.php', 'OCP\\Template' => __DIR__ . '/../../..' . '/lib/public/Template.php', diff --git a/lib/private/AppFramework/Bootstrap/RegistrationContext.php b/lib/private/AppFramework/Bootstrap/RegistrationContext.php index f86890441243b..2b524b610ac46 100644 --- a/lib/private/AppFramework/Bootstrap/RegistrationContext.php +++ b/lib/private/AppFramework/Bootstrap/RegistrationContext.php @@ -45,6 +45,7 @@ use OCP\SystemReport\ISystemReportSection; use OCP\Talk\ITalkBackend; use OCP\TaskProcessing\ITaskType; +use OCP\Teams\ITeamAppProvider; use OCP\Teams\ITeamResourceProvider; use OCP\TextProcessing\IProvider as ITextProcessingProvider; use OCP\Translation\ITranslationProvider; @@ -163,6 +164,9 @@ class RegistrationContext { /** @var ServiceRegistration[] */ private array $teamResourceProviders = []; + /** @var ServiceRegistration[] */ + private array $teamAppProviders = []; + /** @var ServiceRegistration<\OCP\TaskProcessing\IProvider>[] */ private array $taskProcessingProviders = []; @@ -422,6 +426,11 @@ public function registerTeamResourceProvider(string $class) : void { ); } + #[\Override] + public function registerTeamAppProvider(string $class): void { + $this->context->registerTeamAppProvider($this->appId, $class); + } + #[\Override] public function registerCalendarRoomBackend(string $class): void { $this->context->registerCalendarRoomBackend( @@ -682,6 +691,13 @@ public function registerTeamResourceProvider(string $appId, string $class) { ); } + /** + * @psalm-param class-string $class + */ + public function registerTeamAppProvider(string $appId, string $class): void { + $this->teamAppProviders[] = new ServiceRegistration($appId, $class); + } + /** * @psalm-param class-string $migratorClass */ @@ -1117,6 +1133,13 @@ public function getTeamResourceProviders(): array { return $this->teamResourceProviders; } + /** + * @return ServiceRegistration[] + */ + public function getTeamAppProviders(): array { + return $this->teamAppProviders; + } + /** * @return ServiceRegistration[] */ diff --git a/lib/public/AppFramework/Bootstrap/IRegistrationContext.php b/lib/public/AppFramework/Bootstrap/IRegistrationContext.php index 598bbb5fdae73..a8edae446ed54 100644 --- a/lib/public/AppFramework/Bootstrap/IRegistrationContext.php +++ b/lib/public/AppFramework/Bootstrap/IRegistrationContext.php @@ -362,6 +362,14 @@ public function registerCalendarRoomBackend(string $class): void; */ public function registerTeamResourceProvider(string $class): void; + /** + * Register app-owned navigation and embedded views for the Teams page. + * + * @param class-string<\OCP\Teams\ITeamAppProvider> $class + * @since 36.0.0 + */ + public function registerTeamAppProvider(string $class): void; + /** * Register an implementation of \OCP\UserMigration\IMigrator that * will handle the implementation of a migrator diff --git a/lib/public/Teams/ITeamAppProvider.php b/lib/public/Teams/ITeamAppProvider.php new file mode 100644 index 0000000000000..1cffd79914254 --- /dev/null +++ b/lib/public/Teams/ITeamAppProvider.php @@ -0,0 +1,63 @@ + + * @since 36.0.0 + */ + public function getRouteAliases(): array; + + /** + * Return navigation entries, available actions and shared resources represented by the entries. + * + * @param array{teamId: string, teamName: string, userId: string, isAdmin: bool, folder: ?TeamFolder, resources: list, navigationEntryIds: list, saveNavigationEntryIds: callable} $context + * @return TeamAppNavigation Entries and optional actions and resource keys for this team. + * @since 36.0.0 + */ + public function getNavigation(array $context): TeamAppNavigation; + + /** + * Execute an app action and return optional replacement navigation entry IDs and an entry ID to open. + * + * @param array{teamId: string, teamName: string, userId: string, isAdmin: bool, folder: ?TeamFolder, resources: list, navigationEntryIds: list, saveNavigationEntryIds: callable} $context + * @param array $parameters + * @return TeamAppActionResult Omitted keys leave the current navigation and open entry unchanged. + * @since 36.0.0 + */ + public function executeAction(array $context, string $actionId, array $parameters): TeamAppActionResult; +} diff --git a/lib/public/Teams/TeamAppAction.php b/lib/public/Teams/TeamAppAction.php new file mode 100644 index 0000000000000..2eeb17d6591d4 --- /dev/null +++ b/lib/public/Teams/TeamAppAction.php @@ -0,0 +1,36 @@ + + * @since 36.0.0 + */ + public function jsonSerialize(): array { + return array_filter(get_object_vars($this), static fn ($value): bool => $value !== null); + } +} diff --git a/lib/public/Teams/TeamAppActionConfirmation.php b/lib/public/Teams/TeamAppActionConfirmation.php new file mode 100644 index 0000000000000..e94ffc4784fa4 --- /dev/null +++ b/lib/public/Teams/TeamAppActionConfirmation.php @@ -0,0 +1,32 @@ + $this->title, 'message' => $this->message]; + } +} diff --git a/lib/public/Teams/TeamAppActionPrompt.php b/lib/public/Teams/TeamAppActionPrompt.php new file mode 100644 index 0000000000000..8ea7838706777 --- /dev/null +++ b/lib/public/Teams/TeamAppActionPrompt.php @@ -0,0 +1,32 @@ + $this->title, 'value' => $this->value], static fn ($value): bool => $value !== null); + } +} diff --git a/lib/public/Teams/TeamAppActionResult.php b/lib/public/Teams/TeamAppActionResult.php new file mode 100644 index 0000000000000..dac5e09225833 --- /dev/null +++ b/lib/public/Teams/TeamAppActionResult.php @@ -0,0 +1,36 @@ +|null $navigationEntryIds + * @since 36.0.0 + */ + public function __construct( + public readonly ?array $navigationEntryIds = null, + public readonly ?string $open = null, + ) { + } + + /** + * @return array{navigationEntryIds?: list, open?: string} + * @since 36.0.0 + */ + public function jsonSerialize(): array { + return array_filter(get_object_vars($this), static fn ($value): bool => $value !== null); + } +} diff --git a/lib/public/Teams/TeamAppEntry.php b/lib/public/Teams/TeamAppEntry.php new file mode 100644 index 0000000000000..6b65c222af628 --- /dev/null +++ b/lib/public/Teams/TeamAppEntry.php @@ -0,0 +1,44 @@ +|null $legacyIds + * @param list|null $actions + * @param array|null $viewData + * @since 36.0.0 + */ + public function __construct( + public readonly string $id, + public readonly string $label, + public readonly ?string $icon = null, + public readonly ?string $iconSvg = null, + public readonly ?string $iconEmoji = null, + public readonly ?string $href = null, + public readonly ?array $legacyIds = null, + public readonly ?array $actions = null, + public readonly ?array $viewData = null, + ) { + } + + /** + * @return array + * @since 36.0.0 + */ + public function jsonSerialize(): array { + return array_filter(get_object_vars($this), static fn ($value): bool => $value !== null); + } +} diff --git a/lib/public/Teams/TeamAppNavigation.php b/lib/public/Teams/TeamAppNavigation.php new file mode 100644 index 0000000000000..18d38092ec924 --- /dev/null +++ b/lib/public/Teams/TeamAppNavigation.php @@ -0,0 +1,38 @@ + $entries + * @param list|null $actions + * @param list|null $resourceKeys + * @since 36.0.0 + */ + public function __construct( + public readonly array $entries, + public readonly ?array $actions = null, + public readonly ?array $resourceKeys = null, + ) { + } + + /** + * @return array{entries: list, actions?: list, resourceKeys?: list} + * @since 36.0.0 + */ + public function jsonSerialize(): array { + return array_filter(get_object_vars($this), static fn ($value): bool => $value !== null); + } +} diff --git a/lib/public/Teams/TeamAppResource.php b/lib/public/Teams/TeamAppResource.php new file mode 100644 index 0000000000000..73c518a640a2a --- /dev/null +++ b/lib/public/Teams/TeamAppResource.php @@ -0,0 +1,36 @@ + + * @since 36.0.0 + */ + public function jsonSerialize(): array { + return array_filter(get_object_vars($this), static fn ($value): bool => $value !== null); + } +} diff --git a/lib/public/Teams/TeamAppResourceKey.php b/lib/public/Teams/TeamAppResourceKey.php new file mode 100644 index 0000000000000..f9d3c710f5371 --- /dev/null +++ b/lib/public/Teams/TeamAppResourceKey.php @@ -0,0 +1,32 @@ + $this->providerId, 'resourceId' => $this->resourceId]; + } +} diff --git a/lib/public/Teams/TeamAppResourcePicker.php b/lib/public/Teams/TeamAppResourcePicker.php new file mode 100644 index 0000000000000..f3d284080b954 --- /dev/null +++ b/lib/public/Teams/TeamAppResourcePicker.php @@ -0,0 +1,37 @@ + $resources + * @param list|null $selectedIds + * @since 36.0.0 + */ + public function __construct( + public readonly string $title, + public readonly array $resources, + public readonly ?array $selectedIds = null, + ) { + } + + /** + * @return array{title: string, resources: list, selectedIds?: list} + * @since 36.0.0 + */ + public function jsonSerialize(): array { + return array_filter(get_object_vars($this), static fn ($value): bool => $value !== null); + } +} diff --git a/lib/public/Teams/TeamAppRouteAlias.php b/lib/public/Teams/TeamAppRouteAlias.php new file mode 100644 index 0000000000000..c016153ebff08 --- /dev/null +++ b/lib/public/Teams/TeamAppRouteAlias.php @@ -0,0 +1,32 @@ + $this->path, 'entryParam' => $this->entryParam]; + } +} diff --git a/tests/lib/AppFramework/Bootstrap/RegistrationContextTest.php b/tests/lib/AppFramework/Bootstrap/RegistrationContextTest.php index a3f87e7078edc..a34eee0183558 100644 --- a/tests/lib/AppFramework/Bootstrap/RegistrationContextTest.php +++ b/tests/lib/AppFramework/Bootstrap/RegistrationContextTest.php @@ -15,6 +15,7 @@ use OC\Core\Middleware\TwoFactorMiddleware; use OCP\AppFramework\App; use OCP\EventDispatcher\IEventDispatcher; +use OCP\Teams\ITeamAppProvider; use Psr\Log\LoggerInterface; use Test\TestCase; @@ -146,6 +147,17 @@ public function testRegisterUserMigrator(): void { ); } + public function testRegisterTeamAppProvider(): void { + $this->context->for('text')->registerTeamAppProvider(ITeamAppProvider::class); + $this->context->for('whiteboard')->registerTeamAppProvider(ITeamAppProvider::class); + + $this->assertEquals([ + new ServiceRegistration('text', ITeamAppProvider::class), + new ServiceRegistration('whiteboard', ITeamAppProvider::class), + ], $this->context->getTeamAppProviders()); + $this->assertSame([], $this->context->getTeamResourceProviders()); + } + public static function dataProvider_TrueFalse(): array { return[ [true], diff --git a/tests/lib/Teams/TeamAppDataTest.php b/tests/lib/Teams/TeamAppDataTest.php new file mode 100644 index 0000000000000..a37dacc7a30cc --- /dev/null +++ b/tests/lib/Teams/TeamAppDataTest.php @@ -0,0 +1,90 @@ +assertSame('page/:fileId', $alias->path); + $this->assertSame('fileId', $alias->entryParam); + $this->assertSame(['path' => 'page/:fileId', 'entryParam' => 'fileId'], $alias->jsonSerialize()); + } + + public function testSerializesNestedNavigation(): void { + $prompt = new TeamAppActionPrompt('Name', ''); + $confirmation = new TeamAppActionConfirmation('Delete', 'Delete this item?'); + $resource = new TeamAppResource('42', 'Board', '/Board.whiteboard', 'resource-icon', '', 'emoji'); + $picker = new TeamAppResourcePicker('Add to navigation', [$resource], []); + $action = new TeamAppAction('rename', 'Rename', 'action-icon', $prompt, $confirmation, $picker); + $entry = new TeamAppEntry('42', 'Board', 'entry-icon', '', 'emoji', '/board/42', ['board-42'], [$action], ['path' => '/Board.whiteboard']); + $key = new TeamAppResourceKey('whiteboard', '42'); + $navigation = new TeamAppNavigation([$entry], [$action], [$key]); + $expectedAction = [ + 'id' => 'rename', 'label' => 'Rename', 'icon' => 'action-icon', + 'prompt' => ['title' => 'Name', 'value' => ''], + 'confirm' => ['title' => 'Delete', 'message' => 'Delete this item?'], + 'resourcePicker' => ['title' => 'Add to navigation', 'resources' => [[ + 'id' => '42', 'label' => 'Board', 'description' => '/Board.whiteboard', + 'icon' => 'resource-icon', 'iconSvg' => '', 'iconEmoji' => 'emoji', + ]], 'selectedIds' => []], + ]; + + $this->assertSame([$entry], $navigation->entries); + $this->assertSame([$action], $entry->actions); + $this->assertSame($picker, $action->resourcePicker); + $this->assertSame(['entries' => [[ + 'id' => '42', 'label' => 'Board', 'icon' => 'entry-icon', 'iconSvg' => '', 'iconEmoji' => 'emoji', + 'href' => '/board/42', 'legacyIds' => ['board-42'], 'actions' => [$expectedAction], + 'viewData' => ['path' => '/Board.whiteboard'], + ]], 'actions' => [$expectedAction], 'resourceKeys' => [['providerId' => 'whiteboard', 'resourceId' => '42']]], $this->decode($navigation)); + } + + public function testOmitsAbsentOptionalFields(): void { + $this->assertSame(['entries' => []], $this->decode(new TeamAppNavigation([]))); + $this->assertSame(['id' => '42', 'label' => 'Board'], $this->decode(new TeamAppEntry('42', 'Board'))); + $this->assertSame(['id' => 'rename', 'label' => 'Rename'], $this->decode(new TeamAppAction('rename', 'Rename'))); + $this->assertSame(['title' => 'Name'], $this->decode(new TeamAppActionPrompt('Name'))); + $this->assertSame(['id' => '42', 'label' => 'Board'], $this->decode(new TeamAppResource('42', 'Board'))); + $this->assertSame(['title' => 'Boards', 'resources' => []], $this->decode(new TeamAppResourcePicker('Boards', []))); + $this->assertSame([], $this->decode(new TeamAppActionResult())); + } + + public function testPreservesExplicitEmptyLists(): void { + $this->assertSame(['entries' => [], 'actions' => [], 'resourceKeys' => []], $this->decode(new TeamAppNavigation([], [], []))); + $this->assertSame(['id' => '42', 'label' => 'Board', 'legacyIds' => [], 'actions' => [], 'viewData' => []], $this->decode(new TeamAppEntry('42', 'Board', legacyIds: [], actions: [], viewData: []))); + $this->assertSame(['navigationEntryIds' => []], $this->decode(new TeamAppActionResult(navigationEntryIds: []))); + } + + public function testSerializesActionResult(): void { + $result = new TeamAppActionResult(['42'], '42'); + + $this->assertSame(['42'], $result->navigationEntryIds); + $this->assertSame('42', $result->open); + $this->assertSame(['navigationEntryIds' => ['42'], 'open' => '42'], $this->decode($result)); + $this->assertSame(['open' => '42'], $this->decode(new TeamAppActionResult(open: '42'))); + } + + private function decode(\JsonSerializable $value): array { + return json_decode(json_encode($value, JSON_THROW_ON_ERROR), true, 512, JSON_THROW_ON_ERROR); + } +}