From 704a7b2627a9824e54ea2ce92496f936fb852011 Mon Sep 17 00:00:00 2001 From: Janina Hiob Date: Mon, 26 Oct 2026 15:01:04 +0100 Subject: [PATCH] PHP only registration Signed-off-by: Janina Hiob --- README.md | 94 +++ TESTING.md | 56 ++ appinfo/routes.php | 4 + lib/Controller/PageController.php | 11 +- .../TeamAppNavigationController.php | 163 +++++ lib/Controller/TeamTabsController.php | 33 +- lib/Service/TeamAppProviderRegistry.php | 89 +++ src/main.ts | 5 +- src/teams/App.vue | 4 + src/teams/api.ts | 208 +----- src/teams/assets/collectives.svg | 12 - src/teams/assets/collectives.svg.license | 2 - src/teams/assets/deck.svg | 8 - src/teams/assets/deck.svg.license | 2 - src/teams/components/TeamAppIcon.spec.ts | 48 ++ src/teams/components/TeamAppIcon.vue | 39 ++ src/teams/components/TeamAppPrompt.vue | 36 ++ .../components/TeamAppResourcePicker.spec.ts | 109 ++++ .../components/TeamAppResourcePicker.vue | 128 ++++ src/teams/components/TeamNavigation.spec.ts | 66 ++ src/teams/components/TeamNavigation.vue | 599 ++++-------------- src/teams/firstTab.spec.ts | 49 +- src/teams/firstTab.ts | 68 +- src/teams/integrations/prompt.ts | 43 ++ src/teams/integrations/registry.spec.ts | 60 ++ src/teams/integrations/registry.ts | 71 +++ src/teams/integrations/resourcePicker.spec.ts | 71 +++ src/teams/integrations/resourcePicker.ts | 73 +++ src/teams/integrations/store.spec.ts | 336 ++++++++++ src/teams/integrations/store.ts | 280 ++++++++ src/teams/integrations/types.ts | 46 ++ src/teams/resourcesStore.spec.ts | 80 +-- src/teams/resourcesStore.ts | 86 +-- src/teams/router.spec.ts | 30 + src/teams/router.ts | 10 +- src/teams/types.ts | 23 +- src/teams/views/PageView.vue | 168 ----- src/teams/views/TeamAppView.spec.ts | 255 ++++++++ src/teams/views/TeamAppView.vue | 72 +++ src/teams/views/TeamHomeView.spec.ts | 102 +++ src/teams/views/TeamHomeView.vue | 33 +- src/teams/views/TeamLandingView.vue | 8 +- src/teams/views/TeamPage.vue | 60 +- src/window.d.ts | 8 - ...ramework_bootstrap_registrationcontext.php | 14 + .../lib/Controller/PageControllerTest.php | 9 + .../TeamAppNavigationControllerTest.php | 273 ++++++++ .../Service/TeamAppProviderRegistryTest.php | 132 ++++ tsconfig.json | 1 + 49 files changed, 3036 insertions(+), 1141 deletions(-) create mode 100644 lib/Controller/TeamAppNavigationController.php create mode 100644 lib/Service/TeamAppProviderRegistry.php delete mode 100644 src/teams/assets/collectives.svg delete mode 100644 src/teams/assets/collectives.svg.license delete mode 100644 src/teams/assets/deck.svg delete mode 100644 src/teams/assets/deck.svg.license create mode 100644 src/teams/components/TeamAppIcon.spec.ts create mode 100644 src/teams/components/TeamAppIcon.vue create mode 100644 src/teams/components/TeamAppPrompt.vue create mode 100644 src/teams/components/TeamAppResourcePicker.spec.ts create mode 100644 src/teams/components/TeamAppResourcePicker.vue create mode 100644 src/teams/components/TeamNavigation.spec.ts create mode 100644 src/teams/integrations/prompt.ts create mode 100644 src/teams/integrations/registry.spec.ts create mode 100644 src/teams/integrations/registry.ts create mode 100644 src/teams/integrations/resourcePicker.spec.ts create mode 100644 src/teams/integrations/resourcePicker.ts create mode 100644 src/teams/integrations/store.spec.ts create mode 100644 src/teams/integrations/store.ts create mode 100644 src/teams/integrations/types.ts create mode 100644 src/teams/router.spec.ts delete mode 100644 src/teams/views/PageView.vue create mode 100644 src/teams/views/TeamAppView.spec.ts create mode 100644 src/teams/views/TeamAppView.vue create mode 100644 src/teams/views/TeamHomeView.spec.ts create mode 100644 tests/unit/lib/Controller/TeamAppNavigationControllerTest.php create mode 100644 tests/unit/lib/Service/TeamAppProviderRegistryTest.php diff --git a/README.md b/README.md index 8a769155b..e3d70c2a8 100644 --- a/README.md +++ b/README.md @@ -22,3 +22,97 @@ 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. +- PHP context contains the authenticated user ID, team ID/name, server-checked + admin role, team folder, shared resources, `navigationEntryIds`, and a host-owned `saveNavigationEntryIds` + callback. It is never constructed from browser-supplied role or resource data. +- Actions are data: `id`, `label`, optional `icon`, and one optional `dialog`. + PHP accepts a `TeamAppActionPrompt`, `TeamAppActionConfirmation`, or + `TeamAppResourcePicker` as the `dialog` constructor argument. JSON uses + `{type: "prompt", title, value?}`, `{type: "confirm", title, message}`, or + `{type: "resourcePicker", title, resources, selectedIds?}`. The host refreshes the + action description before opening its dialog. Cancel sends no mutation; + an empty picker selection explicitly clears navigation entries. At most 100 resources + can be selected. +- The host posts user input to PHP `executeAction($context, $actionId, $parameters)`. + Prompt input is `parameters.value`; picker input is `parameters.ids`. + The provider rechecks access and validates input. It can return `navigationEntryIds` to + update provider-scoped metadata and `open` to navigate after the host refreshes. + The PHP `saveNavigationEntryIds` callback allows creation to roll back a new file if saving + its navigation entry fails. Failures must propagate instead of returning success-shaped data. +- Full app navigation uses authenticated + `GET /ocs/v2.php/apps/circles/teams/{teamId}/apps/{providerId}/navigation`. + Actions use CSRF-protected `POST` to the corresponding + `/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. +- Members can read navigation entry IDs at + `GET /ocs/v2.php/apps/circles/teams/{teamId}/apps/{providerId}/navigation/selection`. + Admins/owners can write `{ "navigationEntryIds": ["42"] }` with `PUT`. Responses contain + `{ "navigationEntryIds": [...] }` inside the OCS data envelope. 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 `teamsAppPins_*` 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..bd80bd7d4 100644 --- a/TESTING.md +++ b/TESTING.md @@ -12,6 +12,62 @@ 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; 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..289ad546f 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -50,6 +50,10 @@ ['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#getNavigationEntryIds', 'url' => '/teams/{circleId}/apps/{providerId}/navigation/selection', 'verb' => 'GET'], + ['name' => 'TeamAppNavigation#setNavigationEntryIds', 'url' => '/teams/{circleId}/apps/{providerId}/navigation/selection', 'verb' => 'PUT'], + ['name' => 'TeamAppNavigation#getNavigation', 'url' => '/teams/{circleId}/apps/{providerId}/navigation', 'verb' => 'GET'], + ['name' => 'TeamAppNavigation#action', 'url' => '/teams/{circleId}/apps/{providerId}/actions/{actionId}', '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..417cdef3c --- /dev/null +++ b/lib/Controller/TeamAppNavigationController.php @@ -0,0 +1,163 @@ +authorize($circleId, $providerId, false); + try { + $circle = $this->circleRequest->getCircle($circleId); + } catch (CircleNotFoundException) { + throw new OCSNotFoundException('Team not found'); + } + $navigationEntryIds = json_decode($circle->getSettings()[$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 new DataResponse(['navigationEntryIds' => array_values(array_unique($navigationEntryIds))]); + } + + /** @param list $navigationEntryIds */ + #[NoAdminRequired] + public function setNavigationEntryIds(string $circleId, string $providerId, array $navigationEntryIds): DataResponse { + $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 new DataResponse(['navigationEntryIds' => $navigationEntryIds]); + } + + /** @return DataResponse */ + #[NoAdminRequired] + public function getNavigation(string $circleId, string $providerId): DataResponse { + $context = $this->context($circleId, $providerId, false); + return new DataResponse($this->providers->getProviders()[$providerId]->getNavigation($context)); + } + + /** @return DataResponse */ + #[NoAdminRequired] + public function action(string $circleId, string $providerId, string $actionId, array $parameters = []): DataResponse { + $context = $this->context($circleId, $providerId, true); + $provider = $this->providers->getProviders()[$providerId]; + $navigation = $provider->getNavigation($context); + $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($context, $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->navigationEntryIds !== null) { + $this->setNavigationEntryIds($circleId, $providerId, $result->navigationEntryIds); + } + return new DataResponse($result); + } + + private function context(string $circleId, string $providerId, bool $write): array { + $user = $this->authorize($circleId, $providerId, $write); + $team = $this->teamManager->getTeam($circleId, $user->getUID()); + if ($team === null) { + throw new OCSNotFoundException('Team not found'); + } + $member = $this->permissionService->userMustBeMember($user->getUID(), $circleId); + return [ + 'teamId' => $circleId, + 'teamName' => $team->getDisplayName(), + 'userId' => $user->getUID(), + 'isAdmin' => $member->getLevel() >= Member::LEVEL_ADMIN, + 'folder' => $this->teamManager->getTeamFolderProvider()?->getTeamFolder($circleId), + 'resources' => $this->teamManager->getSharedWith($circleId, $user->getUID()), + 'navigationEntryIds' => $this->getNavigationEntryIds($circleId, $providerId)->getData()['navigationEntryIds'], + 'saveNavigationEntryIds' => fn (array $navigationEntryIds) => $this->setNavigationEntryIds($circleId, $providerId, $navigationEntryIds), + ]; + } + + private function authorize(string $circleId, string $providerId, bool $write): IUser { + $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 (!isset($this->providers->getProviders()[$providerId])) { + throw new OCSNotFoundException('Teams app provider not found'); + } + return $user; + } + + private function settingKey(string $providerId): string { + return 'teamsAppPins_' . $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..ba799840e --- /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]); + } + $seen = []; + 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($seen[$appId])) { + throw new UnexpectedValueException('Invalid or duplicate Teams app provider: ' . $appId); + } + $seen[$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..7ca4ec322 100644 --- a/src/main.ts +++ b/src/main.ts @@ -7,18 +7,17 @@ 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 -// the migration (the new Teams code uses the Pinia `teams` store). app.use(store) app.use(router) -// Provides t/n/logger/OC/OCA to the ported (Options API) Contacts components. app.mixin(LegacyGlobalMixin) app.mount('#circles-teams') diff --git a/src/teams/App.vue b/src/teams/App.vue index 9b053eba9..43aa181f5 100644 --- a/src/teams/App.vue +++ b/src/teams/App.vue @@ -8,6 +8,8 @@ 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 TeamAppResourcePicker from './components/TeamAppResourcePicker.vue' import TeamCreationWizard from './components/TeamCreationWizard.vue' import TeamNavigation from './components/TeamNavigation.vue' import { useTeamsStore } from './store.ts' @@ -31,6 +33,8 @@ onMounted(() => store.loadTeams()) + + diff --git a/src/teams/api.ts b/src/teams/api.ts index 49dd4e4c2..94817f08a 100644 --- a/src/teams/api.ts +++ b/src/teams/api.ts @@ -3,11 +3,10 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ +import type { TeamFolder } from '#server-types/teams' 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 +325,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 { @@ -444,117 +436,6 @@ export async function updateTeamFolderQuota(teamId: string, quota: number): Prom return data.ocs.data.folder } -/** - * 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. - * - * @param page - The team page to rename - * @param name - The new page name (without extension) - */ -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 }, - ) -} - /** * Fetch the team-level tab order (readable by every member). * @@ -590,91 +471,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.vue b/src/teams/components/TeamAppPrompt.vue new file mode 100644 index 000000000..27bb7d83b --- /dev/null +++ b/src/teams/components/TeamAppPrompt.vue @@ -0,0 +1,36 @@ + + + + + diff --git a/src/teams/components/TeamAppResourcePicker.spec.ts b/src/teams/components/TeamAppResourcePicker.spec.ts new file mode 100644 index 000000000..0d1115ba0 --- /dev/null +++ b/src/teams/components/TeamAppResourcePicker.spec.ts @@ -0,0 +1,109 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { mdiFileDocumentOutline } from '@mdi/js' +import { mount } from '@vue/test-utils' +import { afterEach, describe, expect, it } from 'vitest' +import { nextTick } from 'vue' +import TeamAppIcon from './TeamAppIcon.vue' +import TeamAppResourcePicker from './TeamAppResourcePicker.vue' +import { chooseTeamResources, finishResourcePicker, resourcePickerState } from '../integrations/resourcePicker.ts' + +const stubs = { + NcDialog: { + props: ['name'], + emits: ['update:open'], + template: '
', + }, + NcTextField: { + props: ['modelValue', 'label'], + emits: ['update:modelValue'], + template: '', + }, + NcCheckboxRadioSwitch: { + props: ['modelValue', 'value', 'disabled'], + emits: ['update:modelValue'], + template: '', + }, + NcButton: { + props: ['disabled'], + emits: ['click'], + template: '', + }, + NcIconSvgWrapper: true, + NcEmptyContent: { props: ['name'], template: '

{{ name }}

' }, +} + +const options = { + title: 'Choose boards', + resources: [{ id: '1', label: 'First', description: 'Folder/Sketch' }, { id: '2', label: 'Second' }], + selectedIds: ['1'], +} + +describe('Teams resource picker dialog', () => { + afterEach(() => finishResourcePicker(null)) + + it('uses the shared renderer for emoji, path and default resource icons', () => { + chooseTeamResources({ + title: 'Boards', + resources: [ + { id: '1', label: 'Emoji', icon: '\u{1F4DA}' }, + { id: '2', label: 'Path', icon: 'M3 3H21V21H3Z' }, + { id: '3', label: 'Default' }, + ], + }, new AbortController().signal) + const wrapper = mount(TeamAppResourcePicker, { global: { stubs } }) + expect(wrapper.findAllComponents(TeamAppIcon).map((icon) => icon.props('icon'))) + .toEqual(['\u{1F4DA}', 'M3 3H21V21H3Z', mdiFileDocumentOutline]) + expect(wrapper.get('.team-app-icon__emoji').text()).toBe('\u{1F4DA}') + wrapper.unmount() + }) + + it('searches names and paths without dropping hidden selections, then saves', async () => { + const selection = chooseTeamResources(options, new AbortController().signal) + const wrapper = mount(TeamAppResourcePicker, { global: { stubs } }) + expect(wrapper.get('[role="dialog"]').attributes('aria-label')).toBe('Choose boards') + await wrapper.get('input[aria-label="Search resources"]').setValue('Second') + expect(wrapper.findAll('input[type="checkbox"]')).toHaveLength(1) + await wrapper.get('input[type="checkbox"]').setValue(true) + await wrapper.get('input[aria-label="Search resources"]').setValue('Folder') + expect(wrapper.text()).toContain('First') + expect(resourcePickerState.selectedIds).toEqual(['1', '2']) + await wrapper.findAll('button').find((button) => button.text() === 'Save')!.trigger('click') + await expect(selection).resolves.toEqual(['1', '2']) + wrapper.unmount() + }) + + it('saves an explicitly empty selection', async () => { + const selection = chooseTeamResources(options, new AbortController().signal) + const wrapper = mount(TeamAppResourcePicker, { global: { stubs } }) + await wrapper.get('input[value="1"]').setValue(false) + await wrapper.findAll('button').find((button) => button.text() === 'Save')!.trigger('click') + await expect(selection).resolves.toEqual([]) + wrapper.unmount() + }) + + it('cancels through the dialog close control and on component teardown', async () => { + const first = chooseTeamResources(options, new AbortController().signal) + const wrapper = mount(TeamAppResourcePicker, { global: { stubs } }) + await wrapper.get('button[aria-label="Close"]').trigger('click') + await expect(first).resolves.toBeNull() + const second = chooseTeamResources(options, new AbortController().signal) + await nextTick() + wrapper.unmount() + await expect(second).resolves.toBeNull() + }) + + it('shows an empty state and disables additional choices at the limit', async () => { + const resources = Array.from({ length: 101 }, (_, index) => ({ id: String(index), label: `Board ${index}` })) + chooseTeamResources({ title: 'Boards', resources, selectedIds: resources.slice(0, 100).map(({ id }) => id) }, new AbortController().signal) + const wrapper = mount(TeamAppResourcePicker, { global: { stubs } }) + expect(wrapper.get('input[value="100"]').attributes('disabled')).toBeDefined() + expect(wrapper.get('input[value="0"]').attributes('disabled')).toBeUndefined() + await wrapper.get('input[aria-label="Search resources"]').setValue('missing') + expect(wrapper.text()).toContain('No resources found') + wrapper.unmount() + }) +}) diff --git a/src/teams/components/TeamAppResourcePicker.vue b/src/teams/components/TeamAppResourcePicker.vue new file mode 100644 index 000000000..c34e1fdd2 --- /dev/null +++ b/src/teams/components/TeamAppResourcePicker.vue @@ -0,0 +1,128 @@ + + + + + + + diff --git a/src/teams/components/TeamNavigation.spec.ts b/src/teams/components/TeamNavigation.spec.ts new file mode 100644 index 000000000..1deb35d2b --- /dev/null +++ b/src/teams/components/TeamNavigation.spec.ts @@ -0,0 +1,66 @@ +/*! + * 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}', + actions: [{ id: 'rename', label: 'Rename', icon: 'M0 0h24v24z' }], + }], + actions: () => [{ id: 'create', label: 'New', icon: '\u{1F195}' }], + 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'], + template: '
', + }, + NcActionButton: { template: '' }, + NcActions: { template: '
' }, + NcActionSeparator: true, + NcIconSvgWrapper: true, + }, + }, + }) + const entry = wrapper.get('section[aria-label="Handbook"]') + 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) + wrapper.unmount() + }) +}) diff --git a/src/teams/components/TeamNavigation.vue b/src/teams/components/TeamNavigation.vue index 133f55c48..6d4ed1ec8 100644 --- a/src/teams/components/TeamNavigation.vue +++ b/src/teams/components/TeamNavigation.vue @@ -4,428 +4,103 @@ -->