diff --git a/appinfo/routes.php b/appinfo/routes.php index 5ae7dbd85..1399d0564 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -56,6 +56,8 @@ ['name' => 'TeamFavorites#getFavorites', 'url' => '/teams/favorites', 'verb' => 'GET'], ['name' => 'TeamFavorites#setFavorite', 'url' => '/teams/{circleId}/favorite', 'verb' => 'PUT'], ['name' => 'TeamFavorites#reorder', 'url' => '/teams/favorites/order', 'verb' => 'PUT'], + ['name' => 'TeamResourceFavorites#getFavorites', 'url' => '/teams/{circleId}/resource-favorites', 'verb' => 'GET'], + ['name' => 'TeamResourceFavorites#setFavorites', 'url' => '/teams/{circleId}/resource-favorites', 'verb' => 'PUT'], // Teams Dashboard widget endpoint ['name' => 'TeamsDashboard#getCompleteTeamsData', 'url' => '/teams/dashboard/widget', 'verb' => 'GET'], diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 40e034da3..ab0fcc4d5 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -19,6 +19,7 @@ use OCA\Circles\Events\Files\CreatingFileShareEvent; use OCA\Circles\Events\Files\FileShareCreatedEvent; use OCA\Circles\Events\Files\PreparingFileShareEvent; +use OCA\Circles\Events\MembershipsRemovedEvent; use OCA\Circles\Events\PreparingCircleMemberEvent; use OCA\Circles\Events\RemovingCircleMemberEvent; use OCA\Circles\Events\RequestingCircleMemberEvent; @@ -42,6 +43,7 @@ use OCA\Circles\Listeners\NodeEventListener; use OCA\Circles\Listeners\Notifications\RequestingMember as ListenerNotificationsRequestingMember; use OCA\Circles\Listeners\TeamFolderLifecycleListener; +use OCA\Circles\Listeners\TeamResourceFavoritesCleanup; use OCA\Circles\Listeners\UserCreated; use OCA\Circles\Listeners\UserDeleted; use OCA\Circles\Listeners\UserLoggedIn; @@ -120,6 +122,8 @@ public function register(IRegistrationContext $context): void { $context->registerEventListener(UserRemovedEvent::class, GroupMemberRemoved::class); $context->registerEventListener(CreatingCircleEvent::class, TeamFolderLifecycleListener::class); $context->registerEventListener(DestroyingCircleEvent::class, TeamFolderLifecycleListener::class); + $context->registerEventListener(DestroyingCircleEvent::class, TeamResourceFavoritesCleanup::class); + $context->registerEventListener(MembershipsRemovedEvent::class, TeamResourceFavoritesCleanup::class); // Local Events (for Files/Shares/Notifications management) $context->registerEventListener(PreparingCircleMemberEvent::class, ListenerFilesPreparingMemberSendMail::class); diff --git a/lib/ConfigLexicon.php b/lib/ConfigLexicon.php index 2dc7f8e72..cda9dfb20 100644 --- a/lib/ConfigLexicon.php +++ b/lib/ConfigLexicon.php @@ -27,6 +27,8 @@ class ConfigLexicon implements ILexicon { public const TEAM_FOLDER_DEFAULT_QUOTA = 'team_folder_default_quota'; public const TEAM_FOLDER_GROUP_QUOTAS = 'team_folder_group_quotas'; public const DEFAULT_QUOTA = 104857600; // 104857600 bytes ~ 100 MB + // User config, suffixed with the team's single id + public const USER_RESOURCE_FAVORITES_PREFIX = 'teamsFavoriteResources_'; // OIDC public const OIDC_ENABLED = 'oidc_enabled'; diff --git a/lib/Controller/TeamResourceFavoritesController.php b/lib/Controller/TeamResourceFavoritesController.php new file mode 100644 index 000000000..b9c74e5ee --- /dev/null +++ b/lib/Controller/TeamResourceFavoritesController.php @@ -0,0 +1,86 @@ +assertAuthenticatedUserIsMember($circleId); + $favorites = $this->userConfig->getValueArray($userId, $this->appName, ConfigLexicon::USER_RESOURCE_FAVORITES_PREFIX . $circleId, [], true); + + return new DataResponse(['favorites' => $this->sanitize($favorites)]); + } + + /** + * @param list $favorites Entry ids, first to last + */ + #[NoAdminRequired] + public function setFavorites(string $circleId, array $favorites): DataResponse { + $userId = $this->assertAuthenticatedUserIsMember($circleId); + $favorites = $this->sanitize($favorites); + $this->userConfig->setValueArray($userId, $this->appName, ConfigLexicon::USER_RESOURCE_FAVORITES_PREFIX . $circleId, $favorites, true); + + return new DataResponse(['favorites' => $favorites]); + } + + /** + * @return list + */ + private function sanitize(array $favorites): array { + $favorites = array_filter($favorites, static fn ($id): bool => is_string($id) + && $id !== '' + && strlen($id) <= self::MAX_ID_LENGTH); + + return array_slice(array_values(array_unique($favorites)), 0, self::MAX_ENTRIES); + } + + private function assertAuthenticatedUserIsMember(string $circleId): string { + $user = $this->userSession->getUser(); + if ($user === null) { + throw new OCSException('Authentication required', Http::STATUS_UNAUTHORIZED); + } + try { + $this->permissionService->userMustBeMember($user->getUID(), $circleId); + } catch (InsufficientPermissionException $e) { + throw new OCSException($e->getMessage(), Http::STATUS_FORBIDDEN); + } + + return $user->getUID(); + } +} diff --git a/lib/Listeners/TeamResourceFavoritesCleanup.php b/lib/Listeners/TeamResourceFavoritesCleanup.php new file mode 100644 index 000000000..7f61c1b34 --- /dev/null +++ b/lib/Listeners/TeamResourceFavoritesCleanup.php @@ -0,0 +1,65 @@ + + */ +class TeamResourceFavoritesCleanup implements IEventListener { + public function __construct( + private readonly IUserConfig $userConfig, + private readonly FederatedUserService $federatedUserService, + private readonly LoggerInterface $logger, + ) { + } + + #[\Override] + public function handle(Event $event): void { + if ($event instanceof DestroyingCircleEvent) { + $this->userConfig->deleteKey(Application::APP_ID, ConfigLexicon::USER_RESOURCE_FAVORITES_PREFIX . $event->getCircle()->getSingleId()); + return; + } + + if (!$event instanceof MembershipsRemovedEvent) { + return; + } + + foreach ($event->getMemberships() as $membership) { + try { + $federatedUser = $this->federatedUserService->getFederatedUser($membership->getSingleId()); + } catch (\Exception $e) { + $this->logger->debug('Could not resolve the member to clean up its favorite resources', ['exception' => $e]); + continue; + } + if ($federatedUser->getUserType() !== Member::TYPE_USER || !$federatedUser->isLocal()) { + continue; + } + $this->userConfig->deleteUserConfig( + $federatedUser->getUserId(), + Application::APP_ID, + ConfigLexicon::USER_RESOURCE_FAVORITES_PREFIX . $membership->getCircleId(), + ); + } + } +} diff --git a/src/teams/api.spec.ts b/src/teams/api.spec.ts index b8149538a..e7f6f91b4 100644 --- a/src/teams/api.spec.ts +++ b/src/teams/api.spec.ts @@ -16,7 +16,7 @@ vi.mock('@nextcloud/axios', () => ({ })) vi.mock('@nextcloud/router', () => ({ - generateOcsUrl: (path: string) => `/ocs/${path}`, + generateOcsUrl: (path: string, params: Record = {}) => `/ocs/${path.replace(/\{([^}]+)\}/g, (_, key: string) => encodeURIComponent(params[key]))}`, })) vi.mock('../logger.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, joinTeam, setTeamFavorite, reorderFavoriteTeams, fetchResourceFavorites, saveResourceFavorites } = await import('./api.ts') describe('fetchTeams', () => { it('splits active members from discoverable teams', async () => { @@ -184,3 +184,40 @@ describe('favorite teams API', () => { expect(overview.teams.map((team) => [team.id, team.isFavorite])).toEqual([['A', false]]) }) }) + +describe('resource favorites API', () => { + beforeEach(() => vi.resetAllMocks()) + + it('fetches the ordered favorites for the requested team', async () => { + vi.mocked(axios.get).mockResolvedValueOnce({ data: { ocs: { data: { favorites: ['board-2', 'page-1'] } } } }) + + await expect(fetchResourceFavorites('team/1')).resolves.toEqual(['board-2', 'page-1']) + expect(axios.get).toHaveBeenCalledWith('/ocs/apps/circles/teams/team%2F1/resource-favorites') + }) + + it('returns no favorites when none have been saved', async () => { + vi.mocked(axios.get).mockResolvedValueOnce({ data: { ocs: { data: {} } } }) + + await expect(fetchResourceFavorites('team-1')).resolves.toEqual([]) + }) + + it('propagates a failed fetch', async () => { + vi.mocked(axios.get).mockRejectedValueOnce(new Error('offline')) + + await expect(fetchResourceFavorites('team-1')).rejects.toThrow('offline') + }) + + it.each([['board-2', 'page-1'], []])('saves the favorites %j for the requested team', async (...favorites) => { + vi.mocked(axios.put).mockResolvedValueOnce({}) + + await saveResourceFavorites('team/1', favorites) + + expect(axios.put).toHaveBeenCalledWith('/ocs/apps/circles/teams/team%2F1/resource-favorites', { favorites }) + }) + + it('propagates a failed save', async () => { + vi.mocked(axios.put).mockRejectedValueOnce(new Error('offline')) + + await expect(saveResourceFavorites('team-1', ['page-1'])).rejects.toThrow('offline') + }) +}) diff --git a/src/teams/api.ts b/src/teams/api.ts index 49dd4e4c2..b4e651e19 100644 --- a/src/teams/api.ts +++ b/src/teams/api.ts @@ -579,6 +579,30 @@ export async function saveTabOrder(teamId: string, order: string[]): Promise { + const { data } = await axios.get>(generateOcsUrl('apps/circles/teams/{circleId}/resource-favorites', { circleId: teamId })) + return data.ocs.data.favorites ?? [] +} + +/** + * Save the current user's favorite navigation entries of a team. + * + * @param teamId - The team single id + * @param favorites - Entry ids, first to last + */ +export async function saveResourceFavorites(teamId: string, favorites: string[]): Promise { + await axios.put( + generateOcsUrl('apps/circles/teams/{circleId}/resource-favorites', { circleId: teamId }), + { favorites }, + ) +} + /** * Fetch the resources shared to a team from the core teams resource * providers (Talk rooms, calendars, collectives, …). diff --git a/src/teams/components/TeamNavigation.vue b/src/teams/components/TeamNavigation.vue index 133f55c48..39173d5fd 100644 --- a/src/teams/components/TeamNavigation.vue +++ b/src/teams/components/TeamNavigation.vue @@ -7,10 +7,10 @@ import type { RouteLocationRaw } from 'vue-router' import type { TeamPage } from '../api.ts' -import { mdiAccountMultiple, mdiAccountMultipleOutline, mdiArrowTopRight, mdiBookOpenPageVariantOutline, mdiCogOutline, mdiFileDocument, mdiFileDocumentOutline, mdiFolder, mdiFolderOutline, mdiFolderPlusOutline, mdiPlus, mdiShareVariant, mdiShareVariantOutline, mdiTextBoxPlusOutline, mdiTrashCanOutline, mdiViewDashboardOutline } from '@mdi/js' +import { mdiAccountMultiple, mdiAccountMultipleOutline, mdiArrowTopRight, mdiBookOpenPageVariantOutline, mdiCogOutline, mdiFileDocument, mdiFileDocumentOutline, mdiFolder, mdiFolderOutline, mdiFolderPlusOutline, mdiPencilOutline, mdiPlus, mdiShareVariant, mdiShareVariantOutline, mdiStar, mdiStarOutline, mdiTextBoxPlusOutline, mdiTrashCanOutline, mdiViewDashboardOutline } from '@mdi/js' import { showConfirmation, showError, showSuccess } from '@nextcloud/dialogs' import { t } from '@nextcloud/l10n' -import { computed, nextTick, ref } from 'vue' +import { computed, nextTick, ref, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' import { useStore } from 'vuex' import NcActionButton from '@nextcloud/vue/components/NcActionButton' @@ -47,6 +47,8 @@ interface NavigationEntry { page?: TeamPage } +type Section = 'favorites' | 'team' + const route = useRoute() const router = useRouter() const store = useStore() @@ -98,9 +100,11 @@ const newPageInput = ref<{ focus?: () => void } | null>(null) // Team-level entry order, shared by all members and shown in the saved // sequence; entries the order does not know yet (e.g. new pages) go last. const entryOrder = computed(() => teamResources.value.tabOrder) -// While a drag is in progress the entries follow this order live. +// While a drag is in progress the entries of the dragged section follow +// this order live. const dragOrder = ref(null) const draggedId = ref(null) +const dragSection = ref
(null) const entries = computed(() => { const items: NavigationEntry[] = [ @@ -148,7 +152,7 @@ const entries = computed(() => { /** The entries in team-level order (live order while a drag is in progress). */ const orderedEntries = computed(() => { - const order = dragOrder.value ?? entryOrder.value + const order = (dragSection.value === 'team' && dragOrder.value) || entryOrder.value if (order.length === 0) { return entries.value } @@ -160,9 +164,90 @@ const orderedEntries = computed(() => { return [...known, ...unknown] }) +// The current user's favorites, shown in their own order above the team +// entries; favorite ids without a matching entry are skipped. +const favoriteIds = computed(() => new Set(teamResources.value.favorites)) +const favoriteEntries = computed(() => { + const byId = new Map(entries.value.map((entry) => [entry.id, entry])) + const order = (dragSection.value === 'favorites' && dragOrder.value) || teamResources.value.favorites + return order + .map((id) => byId.get(id)) + .filter((entry): entry is NavigationEntry => entry !== undefined) +}) +const teamEntries = computed(() => orderedEntries.value.filter((entry) => !favoriteIds.value.has(entry.id))) + +const sections = computed(() => [ + ...(favoriteEntries.value.length > 0 ? [{ id: 'favorites' as const, entries: favoriteEntries.value }] : []), + { id: 'team' as const, entries: teamEntries.value }, +]) + // Reordering entries changes the team-level order, so team admins and above // only — the server enforces the same level on the save endpoint. const canReorder = computed(() => isTeamAdmin.value) +const canEditFavorites = computed(() => Boolean(circle.value?.isMember) && teamResources.value.favoritesChecked) + +// Only a fully, successfully loaded entry list proves a favorite is gone. +const entriesSettled = computed(() => { + const slot = teamResources.value + return slot.folderChecked && !slot.folderError + && slot.resourcesChecked + && slot.boardsChecked && !slot.boardsError + && slot.pagesChecked && !slot.pagesError +}) + +// Not watching the favorites themselves: a failed save rolls them back and would retry in a loop. +watch([entriesSettled, entries, canEditFavorites], async () => { + if (!entriesSettled.value || !canEditFavorites.value || dragSection.value) { + return + } + const known = new Set(entries.value.map((entry) => entry.id)) + const favorites = teamResources.value.favorites + const kept = favorites.filter((id) => known.has(id)) + if (kept.length === favorites.length) { + return + } + try { + await resourcesStore.saveFavorites(teamId.value, kept) + } catch (error) { + logger.warn('Could not remove stale favorites', { error, teamId: teamId.value }) + } +}, { immediate: true }) + +/** + * Whether entries of a section can be dragged by the current user. + * + * @param section - The section the entry is shown in + */ +function canDrag(section: Section): boolean { + return section === 'team' ? canReorder.value : canEditFavorites.value +} + +/** + * Whether the entry can be added to or removed from the favorites. + * + * @param entry - The navigation entry + */ +function canFavorite(entry: NavigationEntry): boolean { + return canEditFavorites.value && entry.id !== 'home' +} + +/** + * Add the entry to the end of the favorites, or remove it from them. + * + * @param entry - The navigation entry + */ +async function toggleFavorite(entry: NavigationEntry): Promise { + const favorites = teamResources.value.favorites + const updated = favoriteIds.value.has(entry.id) + ? favorites.filter((id) => id !== entry.id) + : [...favorites, entry.id] + try { + await resourcesStore.saveFavorites(teamId.value, updated) + } catch (error) { + logger.error('Could not update the favorites', { error, teamId: teamId.value }) + showError(t('circles', 'Could not update the favorites')) + } +} const showTeamFolderOption = computed(() => teamFolderChecked.value && teamFolder.value === null && canCreateTeamFolder(circle.value)) @@ -336,6 +421,33 @@ async function onCreatePage(name: string): Promise { } } +// The item's built-in `editable` action always renders first and would take +// the inline slot from the favorite star, so the menu starts editing instead. +const navigationItems = new Map void }>() + +/** + * Track the rendered navigation item of an entry. + * + * @param id - The entry id + * @param item - The component instance, or null on unmount + */ +function setNavigationItem(id: string, item: unknown): void { + if (item) { + navigationItems.set(id, item as { handleEdit?: () => void }) + } else { + navigationItems.delete(id) + } +} + +/** + * Open the inline rename form of an entry. + * + * @param entry - The page entry to rename + */ +function startRenaming(entry: NavigationEntry): void { + navigationItems.get(entry.id)?.handleEdit?.() +} + /** * Rename a team page from its entry's inline edit form. The file id is * stable across the move, so the entry and route survive the rename. @@ -402,17 +514,22 @@ async function onDeletePage(page: TeamPage): Promise { } /** - * Start dragging an entry (admins and above only). + * Start dragging an entry within its section. * * @param entry - The dragged entry + * @param section - The section the entry is shown in * @param event - The dragstart event */ -function onEntryDragStart(entry: NavigationEntry, event: DragEvent): void { - if (!canReorder.value) { +function onEntryDragStart(entry: NavigationEntry, section: Section, event: DragEvent): void { + if (!canDrag(section)) { return } + // The full lists, so entries hidden from the section keep their place. + dragOrder.value = section === 'team' + ? orderedEntries.value.map((item) => item.id) + : [...teamResources.value.favorites] + dragSection.value = section draggedId.value = entry.id - dragOrder.value = orderedEntries.value.map((item) => item.id) if (event.dataTransfer) { event.dataTransfer.effectAllowed = 'move' // Firefox refuses to start a drag without data @@ -441,11 +558,12 @@ function onListDragOver(event: DragEvent): void { * Reorder live while the dragged entry hovers another entry. * * @param entry - The hovered entry + * @param section - The section the hovered entry is shown in * @param event - The dragover event */ -function onEntryDragOver(entry: NavigationEntry, event: DragEvent): void { +function onEntryDragOver(entry: NavigationEntry, section: Section, event: DragEvent): void { const order = dragOrder.value - if (!order || !draggedId.value) { + if (!order || !draggedId.value || section !== dragSection.value) { return } // Also accepts the drop on the dragged entry itself, where the pointer @@ -477,14 +595,23 @@ function onEntryDragOver(entry: NavigationEntry, event: DragEvent): void { /** Persist the new order when the drag ends. */ async function onEntryDragEnd(): Promise { const order = dragOrder.value + const section = dragSection.value draggedId.value = null dragOrder.value = null - if (!order || order.join('\n') === orderedEntries.value.map((entry) => entry.id).join('\n')) { + dragSection.value = null + const previous = section === 'favorites' + ? teamResources.value.favorites + : orderedEntries.value.map((entry) => entry.id) + if (!order || order.join('\n') === previous.join('\n')) { return } try { // The store applies the order optimistically and rolls back on failure. - await resourcesStore.saveOrder(teamId.value, order) + if (section === 'favorites') { + await resourcesStore.saveFavorites(teamId.value, order) + } else { + await resourcesStore.saveOrder(teamId.value, order) + } } catch (error) { logger.error('Could not save the navigation order', { error, teamId: teamId.value }) showError(t('circles', 'Could not save the navigation order')) @@ -506,66 +633,85 @@ async function onEntryDragEnd(): Promise {