diff --git a/README.md b/README.md index 8a769155b..d7ce034aa 100644 --- a/README.md +++ b/README.md @@ -22,3 +22,80 @@ 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 +[`ITeamAppProvider`](lib/Contracts/ITeamAppProvider.php). This is a Circles API, +not an OCP bootstrap extension. It complements the existing +`OCP\Teams\ITeamResourceProvider`: shared resources continue to use that API. + +In the owning app's bootstrap, register a listener for +`OCA\Circles\Events\RegisterTeamAppProvidersEvent`. The listener calls +`$event->registerProvider('myapp', MyTeamsProvider::class)`. The provider's +`getId()` must match its owning app ID. Its `load()` method adds its lightweight +browser registration script and any initial state. The registry instantiates +only providers whose apps are enabled for the current user. Use lazy event +listeners rather than eagerly loading the provider when Circles is absent. + +The script registers a browser provider with `window.OCA.Circles.Teams`. +If that API is not present yet, listen once for `circles:teams:ready` and register +then. Check `apiVersion === 1`. The host publishes the API before announcing +readiness and waits for the page's deferred scripts before loading providers. + +```typescript +const provider = { + id: 'myapp', + order: 50, + async load(context) { + return { + entries: [{ id: 'example', label: 'Example' }], + actions: [], + resourceKeys: [], + } + }, + async mount(element, context, entryId) { + context.signal.throwIfAborted() + const view = await mountMyView(element, context.teamId, entryId) + return () => view.destroy() + }, +} +``` + +The complete structural TypeScript contract is in +[`types.ts`](src/teams/integrations/types.ts). Apps may type the subset they use +locally; they do not need runtime imports from Circles or access to its Vue, +Pinia, or router instances. + +- `load(context)` returns stable resource IDs, localized labels/icons, actions, + and either external `href` targets or entries rendered by `mount`. +- `mount` is optional for external-link providers. It must resolve the resource + for the current team and user, respect `context.signal`, and return cleanup. + Never treat an entry ID or pin as proof of authorization. Heavy editors should + be imported lazily here. +- `context` supplies the team, role information, folder, shared resources, + `prompt`, `confirm`, provider-scoped `getPins`/`setPins`, `refresh`, and `open`. + After mutations, await `refresh()` before opening a newly created entry. + The context is invalidated when leaving the team. +- Each action has `id`, `label`, optional MDI `icon`, and `run`. Errors must + propagate to the host, which logs them and notifies the user. Do not return + success-shaped results after a failed operation. +- `resourceKeys` lists only `{ providerId, resourceId }` pairs actually + represented by navigation entries. This prevents duplicate shared-resource + rows without hiding resources when a provider fails. +- Navigation IDs are namespaced as `provider:entry`. Individual entries can + declare `legacyIds` to retain an older saved ordering. A provider can declare + `routeAliases`, such as `{ path: 'page/:fileId', entryParam: 'fileId' }`, to + redirect old team-relative URLs to the generic embedded route. Alias paths + must be one literal segment followed by one parameter. +- Members can read pins at + `GET /ocs/v2.php/apps/circles/teams/{teamId}/apps/{providerId}/pins`. + Admins/owners can write `{ "ids": ["42"] }` with `PUT`. Responses contain + `{ "ids": [...] }` inside the OCS data envelope. IDs must be non-empty strings, + at most 64 bytes each, with at most 100 pins. Pins are shared team metadata, + scoped to a registered provider, and never grant file access. + +Text and Whiteboard provide embedded views. Deck and Collectives contribute +external links. 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/appinfo/routes.php b/appinfo/routes.php index 5ae7dbd85..a05a0336e 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#getNavigation', 'url' => '/teams/{circleId}/apps/{providerId}/navigation', 'verb' => 'GET'], + ['name' => 'TeamAppNavigation#action', 'url' => '/teams/{circleId}/apps/{providerId}/actions/{actionId}', 'verb' => 'POST'], + ['name' => 'TeamAppNavigation#setNavigationEntryIds', 'url' => '/teams/{circleId}/apps/{providerId}/navigation/selection', 'verb' => 'PUT'], ['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 cc12d8b49..7acaf7582 100644 --- a/lib/Controller/PageController.php +++ b/lib/Controller/PageController.php @@ -12,7 +12,7 @@ use OCA\Circles\AppInfo\Application; use OCA\Circles\Service\ConfigService; use OCA\Circles\Service\TeamFolderPolicy; -use OCA\Text\Event\LoadEditor; +use OCA\Circles\Service\TeamAppProviderRegistry; use OCP\AppFramework\Controller; use OCP\AppFramework\Http\Attribute\FrontpageRoute; use OCP\AppFramework\Http\Attribute\NoAdminRequired; @@ -20,7 +20,6 @@ use OCP\AppFramework\Http\NotFoundResponse; use OCP\AppFramework\Http\TemplateResponse; use OCP\AppFramework\Services\IInitialState; -use OCP\EventDispatcher\IEventDispatcher; use OCP\IRequest; use OCP\Teams\ITeamManager; use OCP\Util; @@ -35,7 +34,7 @@ public function __construct( private IInitialState $initialState, private ITeamManager $teamManager, private TeamFolderPolicy $teamFolderPolicy, - private IEventDispatcher $eventDispatcher, + private TeamAppProviderRegistry $teamApps, ) { parent::__construct(Application::APP_ID, $request); } @@ -60,11 +59,7 @@ 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()); 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/Service/TeamAppProviderRegistry.php b/lib/Service/TeamAppProviderRegistry.php new file mode 100644 index 000000000..989ab7ed3 --- /dev/null +++ b/lib/Service/TeamAppProviderRegistry.php @@ -0,0 +1,98 @@ +|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, module: ?string}>, errors: list} */ + public function load(): array { + $loaded = []; + foreach ($this->getProviders() as $appId => $provider) { + try { + $module = $provider->load(); + if ($module === '') { + throw new UnexpectedValueException('Teams provider must declare a browser module URL'); + } + Util::addTranslations($appId, $this->l10nFactory->findLanguage($appId)); + $version = $this->appManager->getAppVersion($appId); + if ($module !== null && $version !== '') { + $module .= (str_contains($module, '?') ? '&' : '?') . 'v=' . rawurlencode($version); + } + $loaded[] = ['id' => $appId, 'order' => $provider->getOrder(), 'routeAliases' => $provider->getRouteAliases(), 'module' => $module]; + } 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..3a7fc0e32 100644 --- a/src/main.ts +++ b/src/main.ts @@ -7,18 +7,23 @@ 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 { installTeamAppRoutes } 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') -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') +initializeTeamApps().then(() => { + installTeamAppRoutes() + const app = createApp(App) + app.use(createPinia()) + app.use(store) + app.use(router) + app.mixin(LegacyGlobalMixin) + app.mount('#circles-teams') +}).catch((error) => { + logger.error('Could not start Teams', { error }) + throw error +}) diff --git a/src/teams/App.vue b/src/teams/App.vue index d8b4881a0..31f22cae3 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' @@ -29,6 +31,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/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..4e6683f73 --- /dev/null +++ b/src/teams/components/TeamAppResourcePicker.spec.ts @@ -0,0 +1,91 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { mount } from '@vue/test-utils' +import { afterEach, describe, expect, it } from 'vitest' +import { nextTick } from '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('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..9d4282c1f --- /dev/null +++ b/src/teams/components/TeamAppResourcePicker.vue @@ -0,0 +1,134 @@ + + + + + + + diff --git a/src/teams/components/TeamNavigation.vue b/src/teams/components/TeamNavigation.vue index 133f55c48..1183f6752 100644 --- a/src/teams/components/TeamNavigation.vue +++ b/src/teams/components/TeamNavigation.vue @@ -4,428 +4,102 @@ -->