diff --git a/README.md b/README.md index 8a769155b..47e90ad98 100644 --- a/README.md +++ b/README.md @@ -22,3 +22,113 @@ You can use the `\Psr\Container\ContainerInterface`, see [dependency injection]( # Testing Front-end tests (Vitest for unit/component, Playwright for end-to-end) are documented in [TESTING.md](TESTING.md). +## App-owned Teams navigation and embedded views + +Apps contribute their own integrations through +`OCP\Teams\ITeamAppProvider`, a public interface in Nextcloud Server since +36.0.0. It complements the existing +`OCP\Teams\ITeamResourceProvider`: shared resources continue to use that API. + +Implement this interface and declare the provider once in the owning app's +`Application::register(IRegistrationContext $context)`: + +```php +$context->registerTeamAppProvider(MyTeamsProvider::class); +``` + +No registration event or event listener is required. The provider's +`getId()` must match its owning app ID. The registry instantiates +only providers whose apps are enabled for the current user, using the core +bootstrap declarations. Implementing the interface alone does not discover a +class: the bootstrap declaration supplies its class name without eagerly loading +it. Apps supporting older server versions can guard the declaration with +`interface_exists(\OCP\Teams\ITeamAppProvider::class)`. + +The registry loads app translations and provides app IDs and ordering to the +frontend. The frontend provider's `getNavigation(context)` fetches navigation +entries and actions from the corresponding PHP provider's `getNavigation()`. + +Embedded content uses existing Smart Picker reference widgets. The Teams page dispatches +`OCP\Collaboration\Reference\RenderReferenceEvent`, allowing existing app listeners +to enqueue their reference scripts/styles. These use the normal `registerWidget` +hooks from `@nextcloud/vue/components/NcRichText`. + +Return an entry with both `href` (the Open/fallback URL) and `referenceUrl` (the +canonical resource URL for preview resolution). The host resolves only the opened +entry through `NcReferenceList`, using the same reference API and widgets as the +Text editor. Existing widgets control their interactive capabilities; supported +interactive views open immediately, without requiring a click to activate them. +The host waits for deferred reference scripts before rendering the list. +Entries with only `href` remain external links. + +The list displays the standard reference and, where supported, a fallback link +preview, while the Open link remains available. Resolution failures are logged by `NcReferenceList`, +as in the Text editor. +Reference-provider authorization and inaccessible-resource presentation still +apply. Widgets must provide real destruction hooks and handle repeated renders +and asynchronous cancellation. Team, entry, or reference URL changes destroy the +previous list; requests made by the list are not cancelled. +The typed entry/action contract remains in server `core/src/types/teams.d.ts`. + +- PHP `getOrder()` controls provider order. PHP `getNavigation($context)` returns stable IDs, + localized labels/icons, actions, and entries with an `href` and + optional `referenceUrl`. Never treat an entry ID as proof of resource access. +- Entry, action and picker-resource `icon` values are either an emoji or SVG path + data for a `0 0 24 24` view box. Paths start with an `M`/`m` move command; + other values render as escaped text. Full SVG markup and image URLs are not supported. +- Both PHP provider methods receive the team ID/name, authenticated user ID, + server-checked admin role, and optional team folder as typed parameters. They do not + receive shared resources or saved navigation IDs; providers resolve team resources + through `ITeamManager::getSharedWith()` when needed. Values are never constructed from + browser-supplied role or resource data. +- Providers return all available navigation entries. Circles adds `selectedEntryIds` to + the navigation response from its saved settings; the client displays selected entries + in the sidebar and offers host-owned add/remove actions for every candidate. Providers + do not receive the saved ID list. +- Actions are data: `id`, `label`, optional `icon`, and an optional prompt or confirmation + dialog. JSON uses `{type: "prompt", title, value?}` or + `{type: "confirm", title, message}`. The host refreshes the action description before + opening its dialog. Cancel sends no mutation. Providers can return navigation ID changes + for app-owned operations, and the host applies them to saved state. At most 100 + navigation entries can be saved. +- Individual entries can be added or removed through the host-owned + `POST /ocs/v2.php/apps/circles/teams/{teamId}/apps/{providerId}/entries/navigation` + endpoint with `entryId` and `inNavigation`. Adding requires the entry to still be + returned by its provider; removing is allowed even if the provider no longer returns it. +- The host posts user input to PHP `executeAction($context, $actionId, $parameters)`. + Prompt input is `parameters.value`. + The provider rechecks access and validates input. It can return + `navigationEntryIdsToAdd`, `navigationEntryIdsToRemove`, and `open`. The host applies + those changes to provider-scoped metadata and refreshes navigation. If saving a new + resource's navigation entry fails, the resource itself is not rolled back; the failure + propagates instead of returning success-shaped data. +- Team navigation loads all providers in one authenticated + `GET /ocs/v2.php/apps/circles/teams/{teamId}/apps/navigation` request. + The response contains `providers`, keyed by provider ID, and `errors`, a list + of failed provider IDs. Team context and saved settings are loaded once; + one provider's failure does not hide another provider's navigation. + Shared-resource aggregation also isolates and logs individual resource-provider + failures, retaining resources and navigation from the remaining providers. + Before opening an action dialog, the host revalidates navigation through the + same batch endpoint and uses the selected provider's current action description. + Actions use CSRF-protected `POST` to + `/ocs/v2.php/apps/circles/teams/{teamId}/apps/{providerId}/actions/{actionId}` + with `{ "parameters": {...} }`. The host requires team + admin access and rechecks the currently advertised action before dispatch. +- Shared resources are listed independently of app navigation. Only the team's + own folder is excluded from "Shared with the team"; other group folders remain visible. +- Navigation IDs use `provider-id` (for example `deck-42`). Circles' `TeamTabsController` + migrates its old `board-*`, `page-*` and `collective` tab-order IDs on read; + the next admin reorder persists canonical IDs. Providers do not need migration + aliases. Embedded views use the generic `team/{teamId}/app/{providerId}/{entryId}` route. +- Saved navigation entry IDs are read server-side into each provider's context. + Admins/owners change selections through provider actions; there are no separate + selection read/write endpoints. IDs must be non-empty strings, + at most 64 bytes each, with at most 100 navigation entries. These are shared team metadata, + scoped to a registered provider, and never grant file access. + Existing `teamsAppNavigation_*` setting keys remain in use to preserve saved navigation. + + Native Teams sections remain in Circles. There is no hard-coded +fallback for older app versions: upgrade the host and integration apps together. +Provider failures are reported separately and do not hide generic shared +resources. diff --git a/TESTING.md b/TESTING.md index 78603f042..d0a8ab064 100644 --- a/TESTING.md +++ b/TESTING.md @@ -12,6 +12,66 @@ The app has two layers of front-end tests: PHP tests (PHPUnit, Psalm) live under `tests/` and are documented separately. +## Teams provider migration checks + +Run the focused host tests with: + +```bash +LANG=C npx vitest run src/teams/integrations src/teams/views/TeamAppView.spec.ts src/teams/firstTab.spec.ts src/teams/resourcesStore.spec.ts +npm run ts:check +``` + +PHP registry and navigation authorization tests are +`tests/unit/lib/Service/TeamAppProviderRegistryTest.php` and +`tests/unit/lib/Controller/TeamAppNavigationControllerTest.php`. The normal PHPUnit +bootstrap requires an installed Nextcloud test instance. Never initialize or +replace the shared development database just to run tests. Collectives' PHP +tests must run only in a disposable isolated test instance. + +On an authenticated development instance, manually verify: + +1. Enable matching Text, Deck, Collectives, and Whiteboard versions. All four + register their own entries and creation actions; an unregistered or disabled + app contributes none. Teams navigation registration is PHP-only: check that + `teamAppProviders` has no module URLs. The page dispatches `RenderReferenceEvent` + to load existing Smart Picker scripts. Navigations come from authenticated PHP + endpoints; the initial load and each refresh make one + `/teams/{teamId}/apps/navigation` request for all providers, not one request + per app. Action dialogs revalidate through this same batch endpoint; no + per-provider navigation or selection requests should be made. + Opened entries resolve through the core references API. +2. Open Text pages through the Teams navigation and reorder entries. Reload: old saved ordering + still applies, and the first preview entry is an eligible landing view even + when it also declares an Open URL. Link-only entries are not landing views. +3. Create a Whiteboard, select existing boards from subfolders, and reload. + Shared navigation entries persist. Rename keeps the entry identity; a duplicate name + cannot overwrite a file. +4. Remove an open Whiteboard from navigation: its file remains in Files. Cancel deletion, then + confirm deletion of a disposable board: only confirmation removes the file. +5. Switch rapidly between teams and preview entries. Widget apps unmount cleanly, + with no previous-team references or late mounts. Interactive widgets must be + active immediately on opening and switching entries, without a click + to activate them. Repeat after leaving a preview idle. +6. Repeat as a member, team admin, and nonmember. Members cannot change navigation entries; + nonmembers cannot read them. Read-only file permissions still apply. +7. Disable an integration or simulate a failed navigation request. Provider failures + show an error, do not leave an indefinite loading indicator, and do not hide + resources from the generic shared list. +8. Test existing file, Deck board, and Collectives page widgets. Text/Whiteboard + use existing file previews rather than Teams-specific editors. No matching + widget, a null reference, or OpenGraph-only resolution must leave the Open link + available. A failed reference resolution is logged by the shared list and the + Open link remains available. Inaccessible references must not expose content. + Check translations and widget styles after a fresh load. +9. Open all apps directly and use Smart Picker outside Teams. Their existing app + bundles and previews must still work; no Teams-specific view module should be + requested. Bypass browser caching when validating rebuilt assets. + +Focused widget lifecycle tests also live in server +`apps/files/src/reference-files.spec.ts`, Deck `src/reference.spec.js`, and +Collectives `src/tests/reference.spec.ts`. They cover enclosing-root cleanup, +interactive rerenders, independent widget instances, and late asynchronous mounts. + ## Vitest (unit & component) ### Run diff --git a/appinfo/routes.php b/appinfo/routes.php index 5ae7dbd85..28a17099e 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -50,6 +50,9 @@ ['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' => 'TeamAppNavigation#getNavigations', 'url' => '/teams/{circleId}/apps/navigation', 'verb' => 'GET'], + ['name' => 'TeamAppNavigation#action', 'url' => '/teams/{circleId}/apps/{providerId}/actions/{actionId}', 'verb' => 'POST'], + ['name' => 'TeamAppNavigation#setEntryNavigation', 'url' => '/teams/{circleId}/apps/{providerId}/entries/navigation', 'verb' => 'POST'], ['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/PageController.php b/lib/Controller/PageController.php index b4687bdd0..91ed09d44 100644 --- a/lib/Controller/PageController.php +++ b/lib/Controller/PageController.php @@ -12,8 +12,8 @@ use OCA\Circles\AppInfo\Application; use OCA\Circles\Service\ConfigService; use OCA\Circles\Service\PermissionService; +use OCA\Circles\Service\TeamAppProviderRegistry; use OCA\Circles\Service\TeamFolderPolicy; -use OCA\Text\Event\LoadEditor; use OCP\AppFramework\Controller; use OCP\AppFramework\Http\Attribute\FrontpageRoute; use OCP\AppFramework\Http\Attribute\NoAdminRequired; @@ -21,6 +21,7 @@ use OCP\AppFramework\Http\NotFoundResponse; use OCP\AppFramework\Http\TemplateResponse; use OCP\AppFramework\Services\IInitialState; +use OCP\Collaboration\Reference\RenderReferenceEvent; use OCP\EventDispatcher\IEventDispatcher; use OCP\IRequest; use OCP\IUserSession; @@ -37,6 +38,7 @@ public function __construct( private IInitialState $initialState, private ITeamManager $teamManager, private TeamFolderPolicy $teamFolderPolicy, + private TeamAppProviderRegistry $teamApps, private IEventDispatcher $eventDispatcher, private IUserSession $userSession, private PermissionService $permissionService, @@ -70,11 +72,8 @@ public function index(): TemplateResponse|NotFoundResponse { Util::addScript(Application::APP_ID, 'teams-main'); Util::addStyle(Application::APP_ID, 'teams-main'); - // Load the Text editor so team pages can be edited inline on their - // tabs. The class only resolves while the Text app is enabled. - if (class_exists(LoadEditor::class)) { - $this->eventDispatcher->dispatchTyped(new LoadEditor()); - } + $this->initialState->provideInitialState('teamAppProviders', $this->teamApps->load()); + $this->eventDispatcher->dispatchTyped(new RenderReferenceEvent()); return new TemplateResponse(Application::APP_ID, 'main'); } diff --git a/lib/Controller/TeamAppNavigationController.php b/lib/Controller/TeamAppNavigationController.php new file mode 100644 index 000000000..33d70d366 --- /dev/null +++ b/lib/Controller/TeamAppNavigationController.php @@ -0,0 +1,235 @@ + $navigationEntryIds */ + private function saveNavigationEntryIds(string $circleId, string $providerId, array $navigationEntryIds): void { + [$user] = $this->authorize($circleId, $providerId, true); + if (!$this->validIds($navigationEntryIds)) { + throw new OCSException('Navigation entry IDs must be a list of at most 100 non-empty resource IDs of at most 64 bytes', Http::STATUS_BAD_REQUEST); + } + $navigationEntryIds = array_values(array_unique($navigationEntryIds)); + $this->federatedUserService->setLocalCurrentUser($user); + $this->circleService->updateSetting($circleId, $this->settingKey($providerId), json_encode($navigationEntryIds, JSON_THROW_ON_ERROR)); + } + + /** @return DataResponse, actions?: list, selectedEntryIds: list}>, errors: list}, array{}> */ + #[NoAdminRequired] + public function getNavigations(string $circleId): DataResponse { + [$user, $member] = $this->authorize($circleId, null, false); + $userId = $user->getUID(); + $isAdmin = $member->getLevel() >= Member::LEVEL_ADMIN; + $folder = $this->teamManager->getTeamFolderProvider()?->getTeamFolder($circleId); + $settings = $this->circleSettings($circleId); + $navigation = []; + $errors = []; + foreach ($this->providers->getProviders() as $providerId => $provider) { + try { + $navigationEntryIds = $this->getSavedNavigationEntryIds($settings, $providerId); + $providerNavigation = $provider->getNavigation($circleId, $userId, $isAdmin, $folder); + $navigation[$providerId] = $this->applySavedNavigationSelection($providerNavigation, $navigationEntryIds); + } catch (Throwable $e) { + $errors[] = $providerId; + $this->logger->error('Could not load Teams app navigation', ['provider' => $providerId, 'teamId' => $circleId, 'exception' => $e]); + } + } + return new DataResponse(['providers' => $navigation, 'errors' => $errors]); + } + + /** @return DataResponse */ + #[NoAdminRequired] + public function action(string $circleId, string $providerId, string $actionId, array $parameters = []): DataResponse { + [$user, $member] = $this->authorize($circleId, $providerId, true); + $teamName = $this->teamName($circleId, $user); + $userId = $user->getUID(); + $isAdmin = $member->getLevel() >= Member::LEVEL_ADMIN; + $folder = $this->teamManager->getTeamFolderProvider()?->getTeamFolder($circleId); + $settings = $this->circleSettings($circleId); + $navigationEntryIds = $this->getSavedNavigationEntryIds($settings, $providerId); + $provider = $this->providers->getProviders()[$providerId]; + $navigation = $this->applySavedNavigationSelection($provider->getNavigation($circleId, $userId, $isAdmin, $folder), $navigationEntryIds); + $actions = $navigation['actions'] ?? []; + foreach ($navigation['entries'] as $entry) { + array_push($actions, ...($entry->actions ?? [])); + } + if (!in_array($actionId, array_map(static fn (TeamAppAction $action): string => $action->id, $actions), true)) { + throw new OCSNotFoundException('Teams app action not found'); + } + try { + $result = $provider->executeAction($circleId, $teamName, $userId, $isAdmin, $folder, $actionId, $parameters); + } catch (\InvalidArgumentException $e) { + throw new OCSException($e->getMessage(), Http::STATUS_BAD_REQUEST); + } catch (\OCP\Files\NotPermittedException $e) { + throw new OCSException($e->getMessage(), Http::STATUS_FORBIDDEN); + } + if ($result->navigationEntryIdsToAdd !== null || $result->navigationEntryIdsToRemove !== null) { + $toAdd = $result->navigationEntryIdsToAdd ?? []; + $toRemove = $result->navigationEntryIdsToRemove ?? []; + if (!$this->validIds($toAdd) || !$this->validIds($toRemove)) { + throw new OCSException('Invalid Teams navigation entry changes', Http::STATUS_BAD_REQUEST); + } + $navigationEntryIds = array_values(array_diff($navigationEntryIds, $toRemove)); + $navigationEntryIds = array_values(array_unique([...$navigationEntryIds, ...$toAdd])); + $this->saveNavigationEntryIds($circleId, $providerId, $navigationEntryIds); + } + return new DataResponse($result); + } + + /** @return DataResponse */ + #[NoAdminRequired] + public function setEntryNavigation(string $circleId, string $providerId, string $entryId, bool $inNavigation): DataResponse { + [$user, $member] = $this->authorize($circleId, $providerId, true); + if (!$this->validIds([$entryId])) { + throw new OCSException('Invalid navigation entry ID', Http::STATUS_BAD_REQUEST); + } + $settings = $this->circleSettings($circleId); + $navigationEntryIds = $this->getSavedNavigationEntryIds($settings, $providerId); + if ($inNavigation) { + $provider = $this->providers->getProviders()[$providerId]; + $folder = $this->teamManager->getTeamFolderProvider()?->getTeamFolder($circleId); + try { + $navigation = $provider->getNavigation($circleId, $user->getUID(), $member->getLevel() >= Member::LEVEL_ADMIN, $folder); + } catch (\InvalidArgumentException $e) { + throw new OCSException($e->getMessage(), Http::STATUS_BAD_REQUEST); + } catch (\OCP\Files\NotPermittedException $e) { + throw new OCSException($e->getMessage(), Http::STATUS_FORBIDDEN); + } + if (!in_array($entryId, array_map(static fn (TeamAppEntry $entry): string => $entry->id, $navigation->entries), true)) { + throw new OCSNotFoundException('Teams app navigation entry not found'); + } + $navigationEntryIds[] = $entryId; + } else { + $navigationEntryIds = array_values(array_diff($navigationEntryIds, [$entryId])); + } + $navigationEntryIds = array_values(array_unique($navigationEntryIds)); + if (!$this->validIds($navigationEntryIds)) { + throw new OCSException('Navigation entry limit reached or invalid navigation entry IDs', Http::STATUS_BAD_REQUEST); + } + $this->saveNavigationEntryIds($circleId, $providerId, $navigationEntryIds); + return new DataResponse([]); + } + + private function teamName(string $circleId, IUser $user): string { + $team = $this->teamManager->getTeam($circleId, $user->getUID()); + if ($team === null) { + throw new OCSNotFoundException('Team not found'); + } + return $team->getDisplayName(); + } + + /** @return array{entries: list, actions?: list, selectedEntryIds: list} */ + private function applySavedNavigationSelection(TeamAppNavigation $navigation, array $navigationEntryIds): array { + $entryIds = array_map(static fn (TeamAppEntry $entry): string => $entry->id, $navigation->entries); + $selectedEntryIds = array_values(array_intersect($navigationEntryIds, $entryIds)); + $response = ['entries' => $navigation->entries, 'selectedEntryIds' => $selectedEntryIds]; + if ($navigation->actions !== null) { + $response['actions'] = $navigation->actions; + } + return $response; + } + + /** @return array */ + private function circleSettings(string $circleId): array { + try { + return $this->circleRequest->getCircle($circleId)->getSettings(); + } catch (CircleNotFoundException) { + throw new OCSNotFoundException('Team not found'); + } + } + + /** + * @param array $settings + * @return list + */ + private function getSavedNavigationEntryIds(array $settings, string $providerId): array { + $navigationEntryIds = json_decode($settings[$this->settingKey($providerId)] ?? '[]', true); + if (!is_array($navigationEntryIds) || !$this->validIds($navigationEntryIds)) { + throw new OCSException('Invalid stored Teams navigation metadata', Http::STATUS_INTERNAL_SERVER_ERROR); + } + return array_values(array_unique($navigationEntryIds)); + } + + /** @return array{IUser, Member} */ + private function authorize(string $circleId, ?string $providerId, bool $write): array { + $user = $this->userSession->getUser(); + if ($user === null) { + throw new OCSException('Authentication required', Http::STATUS_UNAUTHORIZED); + } + try { + $member = $this->permissionService->userMustBeMember($user->getUID(), $circleId); + if ($write) { + $this->permissionService->memberMustBeAtLeastAdmin($member); + } + } catch (InsufficientPermissionException $e) { + throw new OCSException($e->getMessage(), Http::STATUS_FORBIDDEN); + } + if ($providerId !== null && !isset($this->providers->getProviders()[$providerId])) { + throw new OCSNotFoundException('Teams app provider not found'); + } + return [$user, $member]; + } + + private function settingKey(string $providerId): string { + return 'teamsAppNavigation_' . $providerId; + } + + private function validIds(array $ids): bool { + if (!array_is_list($ids) || count($ids) > 100) { + return false; + } + foreach ($ids as $id) { + if (!is_string($id) || $id === '' || strlen($id) > 64 || preg_match('/[\x00-\x1f\x7f]/', $id)) { + return false; + } + } + return true; + } +} diff --git a/lib/Controller/TeamTabsController.php b/lib/Controller/TeamTabsController.php index 1d1db5a39..a5a6558a6 100644 --- a/lib/Controller/TeamTabsController.php +++ b/lib/Controller/TeamTabsController.php @@ -25,6 +25,7 @@ use OCP\IRequest; use OCP\IUser; use OCP\IUserSession; +use OCP\Teams\ITeamManager; /** * Team-level order of the tabs shown on a team's page. @@ -39,7 +40,7 @@ class TeamTabsController extends OCSController { /** * When a team admin drags the sidebar entries into a new order, the - * client sends the server a list of ids like ["team-folder", "page-42", + * client sends the server a list of ids like ["team-folder", "text-42", * "home"], and the server saves that list in the team's settings. * * These limits exist because the server just trusts whatever list it @@ -59,6 +60,7 @@ public function __construct( private readonly FederatedUserService $federatedUserService, private readonly PermissionService $permissionService, private readonly IUserSession $userSession, + private readonly ITeamManager $teamManager, ) { parent::__construct($appName, $request); } @@ -75,7 +77,8 @@ public function getTabOrder(string $circleId): DataResponse { $order = json_decode($circle->getSettings()[self::SETTING_TAB_ORDER] ?? '[]', true); - return new DataResponse(['order' => $this->sanitizeOrder(is_array($order) ? $order : [])]); + $order = $this->sanitizeOrder(is_array($order) ? $order : []); + return new DataResponse(['order' => $this->migrateTabOrder($circleId, $order)]); } /** @@ -103,6 +106,32 @@ public function setTabOrder(string $circleId, array $order): DataResponse { return new DataResponse(['order' => $order]); } + /** + * Read-time migration of legacy tab order to provider-prefixed IDs. + * Unknown IDs and unresolved collectives remain available for later loads. + * Reads never write settings; the next admin reorder saves canonical IDs. + * + * @param list $order + * @return list + */ + private function migrateTabOrder(string $circleId, array $order): array { + $order = preg_replace( + ['/^board-([0-9]+)$/D', '/^page-([0-9]+)$/D'], + ['deck-$1', 'text-$1'], + $order, + ); + $collectivePosition = array_search('collective', $order, true); + if ($collectivePosition !== false) { + foreach ($this->teamManager->getSharedWith($circleId, $this->getAuthenticatedUser()->getUID()) as $resource) { + if ($resource->getProvider()->getId() === 'collectives') { + $order[$collectivePosition] = 'collectives-' . $resource->getId(); + break; + } + } + } + return $this->sanitizeOrder($order); + } + /** * Keep only non-empty strings within the structural bounds, * deduplicated, reindexed. diff --git a/lib/Service/TeamAppProviderRegistry.php b/lib/Service/TeamAppProviderRegistry.php new file mode 100644 index 000000000..d356e094f --- /dev/null +++ b/lib/Service/TeamAppProviderRegistry.php @@ -0,0 +1,89 @@ +|null */ + private ?array $providers = null; + /** @var list */ + private array $errors = []; + + public function __construct( + private Coordinator $bootstrap, + private IAppManager $appManager, + private ContainerInterface $container, + private LoggerInterface $logger, + private IFactory $l10nFactory, + ) { + } + + /** @return array */ + public function getProviders(): array { + if ($this->providers !== null) { + return $this->providers; + } + $this->providers = []; + $registrations = []; + try { + $registrations = $this->bootstrap->getRegistrationContext()?->getTeamAppProviders() ?? []; + } catch (Throwable $e) { + $this->errors[] = 'registration'; + $this->logger->error('Could not discover Teams app providers', ['exception' => $e]); + } + $registeredAppIds = []; + foreach ($registrations as $registration) { + $appId = $registration->getAppId(); + if (!$this->appManager->isEnabledForUser($appId)) { + continue; + } + try { + if (!preg_match('/^[a-z][a-z0-9_]*$/D', $appId) || isset($registeredAppIds[$appId])) { + throw new UnexpectedValueException('Invalid or duplicate Teams app provider: ' . $appId); + } + $registeredAppIds[$appId] = true; + $provider = $this->container->get($registration->getService()); + if (!$provider instanceof ITeamAppProvider || $provider->getId() !== $appId) { + throw new UnexpectedValueException('Teams provider identity must match its owning app'); + } + $this->providers[$appId] = $provider; + } catch (Throwable $e) { + unset($this->providers[$appId]); + $this->errors[] = $appId; + $this->logger->error('Could not register Teams app provider', ['app' => $appId, 'exception' => $e]); + } + } + return $this->providers; + } + + /** @return array{providers: list, errors: list} */ + public function load(): array { + $loaded = []; + foreach ($this->getProviders() as $appId => $provider) { + try { + Util::addTranslations($appId, $this->l10nFactory->findLanguage($appId)); + $loaded[] = ['id' => $appId, 'order' => $provider->getOrder()]; + } catch (Throwable $e) { + $this->errors[] = $appId; + $this->logger->error('Could not load Teams app provider', ['app' => $appId, 'exception' => $e]); + } + } + return ['providers' => $loaded, 'errors' => array_values(array_unique($this->errors))]; + } +} diff --git a/src/main.ts b/src/main.ts index 4adf9dc60..3be08cd98 100644 --- a/src/main.ts +++ b/src/main.ts @@ -7,12 +7,14 @@ import { createPinia } from 'pinia' import { createApp } from 'vue' import App from './teams/App.vue' import { logger } from './logger.ts' +import { initializeTeamApps } from './teams/integrations/registry.ts' import { router } from './teams/router.ts' import LegacyGlobalMixin from './teams/team-page/mixins/LegacyGlobalMixin.js' import store from './teams/team-page/store/index.ts' logger.debug('Mounting Teams app') +initializeTeamApps() const app = createApp(App) app.use(createPinia()) // Vuex store from the ported Contacts team page; coexists with Pinia during diff --git a/src/teams/App.vue b/src/teams/App.vue index 9b053eba9..de2c82ba2 100644 --- a/src/teams/App.vue +++ b/src/teams/App.vue @@ -8,6 +8,7 @@ import { storeToRefs } from 'pinia' import { onMounted } from 'vue' import NcAppContent from '@nextcloud/vue/components/NcAppContent' import NcContent from '@nextcloud/vue/components/NcContent' +import TeamAppPrompt from './components/TeamAppPrompt.vue' import TeamCreationWizard from './components/TeamCreationWizard.vue' import TeamNavigation from './components/TeamNavigation.vue' import { useTeamsStore } from './store.ts' @@ -31,6 +32,7 @@ onMounted(() => store.loadTeams()) + diff --git a/src/teams/api.spec.ts b/src/teams/api.spec.ts index b8149538a..fc57511a6 100644 --- a/src/teams/api.spec.ts +++ b/src/teams/api.spec.ts @@ -32,7 +32,7 @@ vi.mock('./team-page/services/collaborationAutocompletion.js', () => ({ getSuggestions: vi.fn(), })) -const { createTeam, fetchTeams, joinTeam, setTeamFavorite, reorderFavoriteTeams } = await import('./api.ts') +const { createTeam, fetchTeams, getTeamAppNavigations, joinTeam, setTeamFavorite, reorderFavoriteTeams } = await import('./api.ts') describe('fetchTeams', () => { it('splits active members from discoverable teams', async () => { @@ -184,3 +184,16 @@ describe('favorite teams API', () => { expect(overview.teams.map((team) => [team.id, team.isFavorite])).toEqual([['A', false]]) }) }) + +describe('Team app navigation API', () => { + beforeEach(() => vi.resetAllMocks()) + + it('fetches all provider navigation in one team-scoped request', async () => { + const result = { providers: { example: { entries: [] } }, errors: ['broken'] } + const signal = new AbortController().signal + vi.mocked(axios.get).mockResolvedValueOnce({ data: { ocs: { data: result } } }) + + await expect(getTeamAppNavigations('team', signal)).resolves.toEqual(result) + expect(axios.get).toHaveBeenCalledExactlyOnceWith('/ocs/apps/circles/teams/{teamId}/apps/navigation', { signal }) + }) +}) diff --git a/src/teams/api.ts b/src/teams/api.ts index 49dd4e4c2..58c38a390 100644 --- a/src/teams/api.ts +++ b/src/teams/api.ts @@ -3,11 +3,11 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ +import type { TeamFolder } from '#server-types/teams' +import type { TeamAppNavigationResponse } from './integrations/types.ts' import type { DiscoverableTeam, Member, MemberCandidate, Resource, SharedResource, Team, TeamRole } from './types.ts' import axios from '@nextcloud/axios' -import { FileType } from '@nextcloud/files' -import { defaultRootPath, getClient, getDefaultPropfind, resultToNode } from '@nextcloud/files/dav' import { generateOcsUrl } from '@nextcloud/router' import { logger } from '../logger.ts' import { SHARES_TYPES_MEMBER_MAP } from './team-page/models/constants.ts' @@ -326,14 +326,7 @@ export async function deleteTeam(teamId: string): Promise { await axios.delete(generateOcsUrl('apps/circles/circles/{circleId}', { circleId: teamId })) } -/** - * The team folder linked to a team. - */ -export interface TeamFolder { - id: number - mountPoint: string - quota: number | null -} +export type { TeamFolder } from '#server-types/teams' /** Team and its linked folder as exposed to administrators. */ export interface AdminTeamFolder { @@ -445,114 +438,17 @@ export async function updateTeamFolderQuota(teamId: string, quota: number): Prom } /** - * Subfolder of the team folder holding the page files, inside the app's - * own namespace of the hidden `.system` folder: the pages neither clutter - * the files the team actually shares nor the `.system` root itself. - */ -const PAGES_FOLDER = '.system/teams/pages' - -/** - * A team page: a markdown file stored in the team folder's hidden pages - * subfolder, surfaced as a tab on the team. - */ -export interface TeamPage { - fileId: number - /** Page title: the file name without the .md extension. */ - title: string - /** Path relative to the user's files root, as the Text editor expects. */ - filePath: string -} - -/** - * List the team pages: the markdown files in the team folder's hidden - * pages subfolder. - * - * @param mountPoint - The team folder mount point of the current user - */ -export async function fetchTeamPages(mountPoint: string): Promise { - let response - try { - response = await getClient().getDirectoryContents(`${defaultRootPath}/${mountPoint}/${PAGES_FOLDER}`, { - details: true, - data: getDefaultPropfind(), - }) - } catch (error) { - // 404 means the pages subfolder was not created yet (it appears with - // the first page), or the team folder itself has not been physically - // created β€” either way "no pages". - if ((error as { status?: number })?.status === 404) { - return [] - } - throw error - } - const data = Array.isArray(response) ? response : response.data - - return data - .map((entry) => resultToNode(entry, defaultRootPath)) - .filter((node) => node.type === FileType.File - && node.extension?.toLowerCase() === '.md' - && node.fileid !== undefined) - .map((node) => ({ - fileId: node.fileid!, - title: node.basename.slice(0, -node.extension!.length), - filePath: `/${mountPoint}/${PAGES_FOLDER}/${node.basename}`, - })) - .sort((a, b) => a.title.localeCompare(b.title)) -} - -/** - * Create a team page: an empty markdown file in the team folder's hidden - * pages subfolder, which appears with the first page. - * - * @param mountPoint - The team folder mount point of the current user - * @param name - The page name (without extension) - */ -export async function createTeamPage(mountPoint: string, name: string): Promise { - const pagesFolder = `${defaultRootPath}/${mountPoint}/${PAGES_FOLDER}` - try { - // Recursive: `.system` and the pages folder inside it appear with - // the first page. - await getClient().createDirectory(pagesFolder, { recursive: true }) - } catch (error) { - // 405: the folder appeared between the existence probe and the MKCOL. - if ((error as { status?: number })?.status !== 405) { - throw error - } - } - const written = await getClient().putFileContents(`${pagesFolder}/${name}.md`, '', { - // Refuse to overwrite an existing page of the same name - overwrite: false, - }) - // The webdav client returns false instead of throwing on the 412 an - // existing page produces with overwrite disabled. - if (written === false) { - throw Object.assign(new Error('A page with this name already exists'), { status: 412 }) - } -} - -/** - * Delete a team page: remove its markdown file from the team folder. - * - * @param page - The team page to delete - */ -export async function deleteTeamPage(page: TeamPage): Promise { - await getClient().deleteFile(`${defaultRootPath}${page.filePath}`) -} - -/** - * Rename a team page: move its markdown file to the new name within the - * team folder. Refuses to overwrite an existing page of the same name. + * Fetch navigation from all registered app providers for a team. * - * @param page - The team page to rename - * @param name - The new page name (without extension) + * @param teamId - The team single id + * @param signal - Active team scope signal */ -export async function renameTeamPage(page: TeamPage, name: string): Promise { - const directory = page.filePath.slice(0, page.filePath.lastIndexOf('/')) - await getClient().moveFile( - `${defaultRootPath}${page.filePath}`, - `${defaultRootPath}${directory}/${name}.md`, - { overwrite: false }, +export async function getTeamAppNavigations(teamId: string, signal: AbortSignal): Promise<{ providers: Record, errors: string[] }> { + const { data } = await axios.get, errors: string[] }>>( + generateOcsUrl('apps/circles/teams/{teamId}/apps/navigation', { teamId }), + { signal }, ) + return data.ocs.data } /** @@ -590,91 +486,6 @@ export async function fetchTeamResources(teamId: string): Promise { - await axios.post( - generateOcsUrl('apps/collectives/api/v1.0/collectives'), - { name }, - ) -} - -/** - * Create a Deck board linked to a team. The deck endpoint links the board - * to the team itself, so no separate share step is needed. - * - * TODO: calls the deck API directly; should eventually go through a teams - * extension point instead of hardcoding another app's route. - * - * @param teamId - The team single id - * @param title - The board title - */ -export async function createDeckBoard(teamId: string, title: string): Promise { - const res = await axios.post>( - generateOcsUrl('apps/deck/api/v1.0/boards/team'), - { title, teamId }, - ) - if (!res.data.ocs.data.id) { - throw new Error('The board creation response contains no board id') - } -} - -/** A deck board attached to the team itself, shown as a navigation entry. */ -export interface TeamBoard { - id: number - title: string - /** Absolute deep link to the board in the deck app. */ - url: string -} - -/** The subset of deck's serialized board relevant here. */ -interface RawDeckBoard { - id: number - title: string - teamId?: string | null - archived?: boolean - deletedAt?: number -} - -/** - * Pick the deck boards attached to the team itself out of the shared deck - * resources. The generic resource list cannot tell a team board from a - * personally-shared one (and deck's board list omits teamId), so each - * candidate is read individually from deck. - * - * TODO: calls the deck API directly; should eventually go through a teams - * extension point instead of hardcoding another app's route. - * - * @param teamId - The team single id - * @param candidates - The team's shared resources from the deck provider - */ -export async function fetchTeamDeckBoards(teamId: string, candidates: SharedResource[]): Promise { - const responses = await Promise.allSettled(candidates.map((resource) => axios.get>(generateOcsUrl('apps/deck/api/v1.0/board/{boardId}', { boardId: resource.id })))) - - const boards: TeamBoard[] = [] - responses.forEach((response, index) => { - const resource = candidates[index] - if (response.status === 'rejected') { - // The board stays reachable through the shared resources. - logger.warn('Could not read a deck board, not showing it as a tab', { resource, reason: response.reason }) - return - } - const board = response.value.data.ocs.data - if (board.teamId === teamId && !board.archived && (board.deletedAt ?? 0) === 0) { - boards.push({ id: board.id, title: board.title, url: resource.url }) - } - }) - - return boards.sort((a, b) => a.title.localeCompare(b.title)) -} - /** * Search for potential new members (users, groups, emails, contacts, other * teams…) using the same sharee autocompletion endpoint as file sharing. diff --git a/src/teams/assets/collectives.svg b/src/teams/assets/collectives.svg deleted file mode 100644 index 350783861..000000000 --- a/src/teams/assets/collectives.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - diff --git a/src/teams/assets/collectives.svg.license b/src/teams/assets/collectives.svg.license deleted file mode 100644 index f07cb622a..000000000 --- a/src/teams/assets/collectives.svg.license +++ /dev/null @@ -1,2 +0,0 @@ -SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors -SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/src/teams/assets/deck.svg b/src/teams/assets/deck.svg deleted file mode 100644 index 58c1756d8..000000000 --- a/src/teams/assets/deck.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/src/teams/assets/deck.svg.license b/src/teams/assets/deck.svg.license deleted file mode 100644 index cb76539e9..000000000 --- a/src/teams/assets/deck.svg.license +++ /dev/null @@ -1,2 +0,0 @@ -SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors -SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/src/teams/components/TeamAppIcon.spec.ts b/src/teams/components/TeamAppIcon.spec.ts new file mode 100644 index 000000000..5acb586f3 --- /dev/null +++ b/src/teams/components/TeamAppIcon.spec.ts @@ -0,0 +1,48 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { shallowMount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' +import TeamAppIcon from './TeamAppIcon.vue' + +describe('Teams app icons', () => { + it.each(['M3 3H21V21H3Z', 'm3 3h18v18h-18z', ' M .5 -.5 L 24 24 '])('renders SVG path data: %s', (icon) => { + const wrapper = shallowMount(TeamAppIcon, { props: { icon } }) + expect(wrapper.getComponent(NcIconSvgWrapper).props()).toMatchObject({ path: icon.trim(), size: 20 }) + expect(wrapper.find('.team-app-icon__emoji').exists()).toBe(false) + }) + + it.each(['\u{1F4DA}', '\u{1F469}\u{200D}\u{1F4BB}', '\u{1F1E9}\u{1F1EA}', '\u{1F44D}\u{1F3FD}', '1\u{FE0F}\u{20E3}'])('renders emoji as text: %s', (icon) => { + const wrapper = shallowMount(TeamAppIcon, { props: { icon } }) + expect(wrapper.findComponent(NcIconSvgWrapper).exists()).toBe(false) + expect(wrapper.get('.team-app-icon__emoji').text()).toBe(icon) + expect(wrapper.attributes('aria-hidden')).toBe('true') + }) + + it.each([undefined, '', ' \n '])('renders no icon for an empty value: %s', (icon) => { + const wrapper = shallowMount(TeamAppIcon, { props: { icon } }) + expect(wrapper.findComponent(NcIconSvgWrapper).exists()).toBe(false) + expect(wrapper.find('span').exists()).toBe(false) + expect(wrapper.text()).toBe('') + }) + + it('does not interpret unsupported markup as HTML or SVG', () => { + const icon = '' + const wrapper = shallowMount(TeamAppIcon, { props: { icon } }) + expect(wrapper.findComponent(NcIconSvgWrapper).exists()).toBe(false) + expect(wrapper.find('svg').exists()).toBe(false) + expect(wrapper.text()).toBe(icon) + }) + + it('updates when an icon changes between path and emoji', async () => { + const wrapper = shallowMount(TeamAppIcon, { props: { icon: 'M3 3H21V21H3Z' } }) + await wrapper.setProps({ icon: '\u{1F4DA}' }) + expect(wrapper.findComponent(NcIconSvgWrapper).exists()).toBe(false) + expect(wrapper.text()).toBe('\u{1F4DA}') + await wrapper.setProps({ icon: 'm0 0h24v24z' }) + expect(wrapper.getComponent(NcIconSvgWrapper).props('path')).toBe('m0 0h24v24z') + }) +}) diff --git a/src/teams/components/TeamAppIcon.vue b/src/teams/components/TeamAppIcon.vue new file mode 100644 index 000000000..08ec5a600 --- /dev/null +++ b/src/teams/components/TeamAppIcon.vue @@ -0,0 +1,39 @@ + + + + + + + diff --git a/src/teams/components/TeamAppPrompt.spec.ts b/src/teams/components/TeamAppPrompt.spec.ts new file mode 100644 index 000000000..89ea35979 --- /dev/null +++ b/src/teams/components/TeamAppPrompt.spec.ts @@ -0,0 +1,88 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import { defineComponent, nextTick } from 'vue' +import TeamAppPrompt, { promptForName } from './TeamAppPrompt.vue' + +const dialogStub = defineComponent({ + props: { name: { type: String, required: true } }, + template: '

{{ name }}

', +}) + +const buttonStub = defineComponent({ + emits: ['click'], + template: '', +}) + +const textFieldStub = defineComponent({ + props: { modelValue: { type: String, default: '' } }, + emits: ['update:modelValue'], + template: '', +}) + +function mountPrompt() { + return mount(TeamAppPrompt, { + global: { + stubs: { + NcButton: buttonStub, + NcDialog: dialogStub, + NcTextField: textFieldStub, + }, + }, + }) +} + +describe('Team app prompt', () => { + it('resolves with the entered value', async () => { + const wrapper = mountPrompt() + const answer = promptForName('Name', 'Draft', new AbortController().signal) + await nextTick() + expect(wrapper.get('h2').text()).toBe('Name') + await wrapper.get('input').setValue('Sketch') + await wrapper.findAll('button').find((button) => button.text() === 'Save')!.trigger('click') + + await expect(answer).resolves.toBe('Sketch') + expect(wrapper.find('h2').exists()).toBe(false) + wrapper.unmount() + }) + + it('resolves with null when cancelled in the dialog', async () => { + const wrapper = mountPrompt() + const answer = promptForName('Name', undefined, new AbortController().signal) + await nextTick() + await wrapper.findAll('button').find((button) => button.text() === 'Cancel')!.trigger('click') + + await expect(answer).resolves.toBeNull() + expect(wrapper.find('h2').exists()).toBe(false) + wrapper.unmount() + }) + + it('resolves with null when the team scope is aborted', async () => { + const wrapper = mountPrompt() + const controller = new AbortController() + const answer = promptForName('Name', undefined, controller.signal) + await nextTick() + controller.abort() + + await expect(answer).resolves.toBeNull() + await nextTick() + expect(wrapper.find('h2').exists()).toBe(false) + wrapper.unmount() + }) + + it('rejects an already-aborted scope and a concurrent prompt', async () => { + const aborted = new AbortController() + aborted.abort() + expect(() => promptForName('Name', undefined, aborted.signal)).toThrow() + + const controller = new AbortController() + const answer = promptForName('Name', undefined, controller.signal) + expect(() => promptForName('Another name', undefined, new AbortController().signal)).toThrow('Another Teams app dialog is already open') + controller.abort() + await expect(answer).resolves.toBeNull() + }) +}) diff --git a/src/teams/components/TeamAppPrompt.vue b/src/teams/components/TeamAppPrompt.vue new file mode 100644 index 000000000..f9fb35239 --- /dev/null +++ b/src/teams/components/TeamAppPrompt.vue @@ -0,0 +1,80 @@ + + + + + diff --git a/src/teams/components/TeamNavigation.spec.ts b/src/teams/components/TeamNavigation.spec.ts new file mode 100644 index 000000000..9ca7ce464 --- /dev/null +++ b/src/teams/components/TeamNavigation.spec.ts @@ -0,0 +1,87 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { mount } from '@vue/test-utils' +import { describe, expect, it, vi } from 'vitest' +import TeamAppIcon from './TeamAppIcon.vue' +import TeamNavigation from './TeamNavigation.vue' + +vi.mock('@nextcloud/dialogs', () => ({ showError: vi.fn() })) +vi.mock('vue-router', () => ({ + useRoute: () => ({ params: { teamId: 'team' }, name: 'team-home' }), + useRouter: () => ({ push: vi.fn() }), +})) +vi.mock('vuex', () => ({ useStore: () => ({ getters: { getCircle: () => ({ isMember: true }) } }) })) +vi.mock('../store.ts', () => ({ useTeamsStore: () => ({ getTeam: () => null }) })) +vi.mock('../resourcesStore.ts', () => ({ useTeamResourcesStore: () => ({ forTeam: () => ({ tabOrder: [] }) }) })) +vi.mock('../composables/useTeamActions.ts', () => ({ + useTeamActions: () => ({ canManage: false, isTeamAdmin: false }), + canCreateTeamFolder: () => false, +})) +vi.mock('../integrations/store.ts', () => ({ + useTeamAppsStore: () => ({ + entries: () => [{ + id: 'text-7', + label: 'Handbook', + icon: '\u{1F4DA}', + providerId: 'text', + referenceUrl: 'https://cloud.example/f/7', + actions: [{ id: 'rename', label: 'Rename', icon: 'M0 0h24v24z' }], + }, { + id: 'calendar-1', + label: 'Calendar', + providerId: 'calendar', + href: '/apps/calendar/1', + referenceUrl: 'http://localhost/apps/collectives/1', + openMode: 'link', + }], + actions: () => [ + { id: 'create', label: 'New', icon: '\u{1F195}' }, + { id: 'circles-add-text-7', label: 'Add Handbook to navigation', icon: 'M0 0h24v24z' }, + ], + errors: () => [], + busy: false, + }), +})) +vi.mock('./TeamHeader.vue', () => ({ default: { template: '
' } })) +vi.mock('./TeamSettingsDialog.vue', () => ({ default: { template: '
' } })) + +describe('Teams navigation icons', () => { + it('uses the shared renderer for entries and every app action menu', () => { + const wrapper = mount(TeamNavigation, { + global: { + stubs: { + NcAppNavigation: { template: '' }, + NcAppNavigationCaption: { template: '
' }, + NcAppNavigationItem: { + props: ['name', 'href', 'to', 'target', 'rel'], + template: '
', + }, + NcActionButton: { template: '' }, + NcActions: { template: '
' }, + NcActionSeparator: { template: '
' }, + NcIconSvgWrapper: true, + }, + }, + }) + const entry = wrapper.get('section[aria-label="Handbook"]') + const directLink = wrapper.get('section[aria-label="Calendar"]') + expect(directLink.attributes('data-href')).toBe('/apps/calendar/1') + expect(directLink.attributes('data-to')).toBeUndefined() + expect(directLink.attributes('data-target')).toBe('_blank') + expect(directLink.attributes('data-rel')).toBe('noopener noreferrer') + expect(entry.attributes('data-target')).toBeUndefined() + const entryIcons = wrapper.findAllComponents(TeamAppIcon).filter((icon) => entry.element.contains(icon.element)) + expect(entryIcons.map((icon) => icon.props('icon'))) + .toEqual(['\u{1F4DA}', 'M0 0h24v24z']) + expect(entry.get('.team-app-icon__emoji').text()).toBe('\u{1F4DA}') + expect(wrapper.findAllComponents(TeamAppIcon).filter((icon) => icon.props('icon') === '\u{1F195}')) + .toHaveLength(2) + expect(wrapper.findAll('.team-action-separator')).toHaveLength(2) + expect(wrapper.findAll('button').map((button) => button.text())) + .toEqual(['πŸ†• New', 'Add Handbook to navigation', 'Rename', 'πŸ†• New', 'Add Handbook to navigation']) + wrapper.unmount() + }) +}) diff --git a/src/teams/components/TeamNavigation.vue b/src/teams/components/TeamNavigation.vue index 133f55c48..6495c76ba 100644 --- a/src/teams/components/TeamNavigation.vue +++ b/src/teams/components/TeamNavigation.vue @@ -4,428 +4,111 @@ -->