diff --git a/lib/Controller/FolderController.php b/lib/Controller/FolderController.php index 17edd127f..f6fa1021e 100644 --- a/lib/Controller/FolderController.php +++ b/lib/Controller/FolderController.php @@ -124,6 +124,7 @@ private function formatFolder(FolderWithMappingsAndCache $folder): array { * @param 'mount_point'|'quota'|'groups'|'acl' $orderBy The key to order by * @param 'asc'|'desc' $order Sort ascending or descending * @param ?string $mountpoint Only return folders with a given mount point + * @param 'all'|'group'|'team' $folderType Only return folders of the given type * @return DataResponse, array{}> * @throws OCSNotFoundException Storage not found * @throws OCSBadRequestException Wrong limit used @@ -139,6 +140,7 @@ public function getFolders( string $orderBy = 'mount_point', string $order = 'asc', ?string $mountpoint = null, + string $folderType = 'all', ): DataResponse { /** * @phpstan-ignore smallerOrEqual.alwaysFalse, booleanAnd.alwaysFalse @@ -161,6 +163,8 @@ public function getFolders( throw new OCSBadRequestException('The order is not allowed.'); } + $folderType = $this->validateFolderType($folderType); + $storageId = $this->getRootFolderStorageId(); if ($storageId === null) { throw new OCSNotFoundException(); @@ -171,7 +175,7 @@ public function getFolders( $folders = []; $i = 0; /** @var string $id */ - foreach ($this->manager->getAllFoldersWithSize($offset, $limit, $orderBy, $order, $mountpoint) as $id => $folder) { + foreach ($this->manager->getAllFoldersWithSize($offset, $limit, $orderBy, $order, $mountpoint, $folderType) as $id => $folder) { // Make them string-indexed for OpenAPI JSON output // JavaScript doesn't preserve JSON object key orders, so we need to manually add this information. $folders[(string)$id] = array_merge($this->formatFolder($folder), [ @@ -195,6 +199,18 @@ public function getFolders( return new DataResponse($folders); } + /** + * @return 'all'|'group'|'team' + * @throws OCSBadRequestException + */ + private function validateFolderType(string $folderType): string { + if (!in_array($folderType, ['all', 'group', 'team'], true)) { + throw new OCSBadRequestException('The folderType is not allowed.'); + } + + return $folderType; + } + /** * Gets a Groupfolder by ID * @@ -640,14 +656,18 @@ public function aclMappingSearch(int $id, string $search = ''): DataResponse { /** * Gets the total number of Groupfolders * + * @param 'all'|'group'|'team' $folderType Only count folders of the given type * @return DataResponse + * @throws OCSBadRequestException Invalid folder type * * 200: Groupfolder count returned */ #[RequireGroupFolderAdmin] #[NoAdminRequired] #[FrontpageRoute(verb: 'GET', url: '/folders/count')] - public function getFoldersCount(): DataResponse { - return new DataResponse(['count' => $this->manager->countAllFolders()]); + public function getFoldersCount(string $folderType = 'all'): DataResponse { + $folderType = $this->validateFolderType($folderType); + + return new DataResponse(['count' => $this->manager->countAllFolders($folderType)]); } } diff --git a/lib/Folder/FolderManager.php b/lib/Folder/FolderManager.php index 493d7c2d3..868817910 100644 --- a/lib/Folder/FolderManager.php +++ b/lib/Folder/FolderManager.php @@ -155,6 +155,7 @@ private function selectWithFileCache(?IQueryBuilder $query = null): IQueryBuilde } /** + * @param 'all'|'group'|'team' $folderType * @return array * @throws Exception */ @@ -164,10 +165,12 @@ public function getAllFoldersWithSize( string $orderBy = 'mount_point', \SortDirection $order = \SortDirection::Ascending, ?string $mountPoint = null, + string $folderType = 'all', ): array { $query = $this->selectWithFileCache(); $query->setFirstResult($offset); $query->setMaxResults($limit); + $this->applyFolderTypeFilter($query, $folderType); if ($orderBy === 'groups') { $query ->leftJoin('f', 'group_folders_groups', 'g', $query->expr()->eq('f.folder_id', 'g.folder_id')) @@ -178,7 +181,7 @@ public function getAllFoldersWithSize( } if ($mountPoint !== null) { - $query->where($query->expr()->eq('mount_point', $query->createNamedParameter($mountPoint))); + $query->andWhere($query->expr()->eq('mount_point', $query->createNamedParameter($mountPoint))); } // Fallback in case two rows are the same after ordering by the $orderBy @@ -1600,11 +1603,26 @@ private function invalidateFolderAclCache(int $folderId): void { $this->canManageACLCache = []; } - public function countAllFolders(): int { + /** + * @param 'all'|'group'|'team' $folderType + */ + public function countAllFolders(string $folderType = 'all'): int { $query = $this->connection->getQueryBuilder(); $query->select($query->func()->count('folder_id')) - ->from('group_folders'); + ->from('group_folders', 'f'); + $this->applyFolderTypeFilter($query, $folderType); $result = $query->executeQuery()->fetchOne(); return is_numeric($result) ? (int)$result : 0; } + + /** + * @param 'all'|'group'|'team' $folderType + */ + private function applyFolderTypeFilter(IQueryBuilder $query, string $folderType): void { + if ($folderType === 'group') { + $query->andWhere($query->expr()->isNull('f.team_circle_id')); + } elseif ($folderType === 'team') { + $query->andWhere($query->expr()->isNotNull('f.team_circle_id')); + } + } } diff --git a/openapi.json b/openapi.json index 3247305c1..2763549f5 100644 --- a/openapi.json +++ b/openapi.json @@ -626,6 +626,20 @@ "default": null } }, + { + "name": "folderType", + "in": "query", + "description": "Only return folders of the given type", + "schema": { + "type": "string", + "default": "all", + "enum": [ + "all", + "group", + "team" + ] + } + }, { "name": "OCS-APIRequest", "in": "header", @@ -2762,6 +2776,20 @@ } ], "parameters": [ + { + "name": "folderType", + "in": "query", + "description": "Only count folders of the given type", + "schema": { + "type": "string", + "default": "all", + "enum": [ + "all", + "group", + "team" + ] + } + }, { "name": "OCS-APIRequest", "in": "header", @@ -2813,6 +2841,34 @@ } } }, + "400": { + "description": "Invalid folder type", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, "401": { "description": "Current user is not logged in", "content": { diff --git a/src/settings/Api.ts b/src/settings/Api.ts index 6c300edec..f0b9d6fbd 100644 --- a/src/settings/Api.ts +++ b/src/settings/Api.ts @@ -11,19 +11,22 @@ import type { Folder, Group, User, AclManage, DelegationCircle, DelegationGroup, addPasswordConfirmationInterceptors(axios) +type FolderType = 'all' | 'group' | 'team' + export class Api { getUrl(endpoint: string): string { return generateUrl(`apps/groupfolders/${endpoint}`) } - async listFolders(offset = 0, limit?: number, orderBy?: string, order?: string): Promise { + async listFolders(offset = 0, limit?: number, orderBy?: string, order?: string, folderType: FolderType = 'all'): Promise { const response = await axios.get>(this.getUrl('folders'), { params: { offset, limit, orderBy, order, + folderType, }, }) return Object.values(response.data.ocs.data) @@ -153,8 +156,10 @@ export class Api { } } - async countFolders(): Promise { - const response = await axios.get>(this.getUrl('folders/count')) + async countFolders(folderType: FolderType = 'all'): Promise { + const response = await axios.get>(this.getUrl('folders/count'), { + params: { folderType }, + }) return response.data.ocs.data.count } diff --git a/src/settings/App.scss b/src/settings/App.scss index 35593bccc..556f5c87b 100644 --- a/src/settings/App.scss +++ b/src/settings/App.scss @@ -282,51 +282,12 @@ } } -.folder-filter-tabs { - display: flex; - gap: var(--default-grid-baseline); - margin: 10px 0; - - button { - padding: 6px 14px; - border: 1px solid var(--color-border); - border-radius: var(--border-radius-large); - background: var(--color-main-background); - color: var(--color-text-lighter); - cursor: pointer; - font-size: 0.9em; - - &:hover { - color: var(--color-main-text); - border-color: var(--color-border-dark); - } - - &.active { - color: var(--color-primary-element-text); - background: var(--color-primary-element); - border-color: var(--color-primary-element); - } - } -} - .folder-list-empty td { padding: 32px 16px; color: var(--color-text-maxcontrast); text-align: center; } -.team-space-badge { - display: inline-block; - margin-right: 6px; - padding: 1px 6px; - border-radius: var(--border-radius); - background: var(--color-background-dark); - color: var(--color-text-maxcontrast); - font-size: 0.75em; - font-weight: 600; - vertical-align: middle; -} - .team-space-locked { color: var(--color-text-maxcontrast); cursor: default; diff --git a/src/settings/App.tsx b/src/settings/App.tsx index 15f8aacc4..745714c8b 100644 --- a/src/settings/App.tsx +++ b/src/settings/App.tsx @@ -40,8 +40,6 @@ const pageSize = 50 export type SortKey = 'mount_point' | 'quota' | 'groups' | 'acl'; -export type FolderFilter = 'all' | 'space' | 'folder'; - export interface AppState { delegatedAdminGroups: DelegationGroup[], delegatedSubAdminGroups: DelegationGroup[], @@ -54,7 +52,6 @@ export interface AppState { editingMountPoint: number; renameMountPoint: string; filter: string; - folderFilter: FolderFilter; sort: SortKey; sortOrder: number; isAdminNextcloud: boolean; @@ -80,7 +77,6 @@ export class App extends Component implements OC.Plugin implements OC.Plugin { + this.api.listFolders(0, pageSize + 1, this.state.sort, this.state.sortOrder === 1 ? 'asc' : 'desc', 'group').then((folders) => { this.setState({ folders }) }) this.api.listGroups().then((groups) => { @@ -101,7 +97,7 @@ export class App extends Component implements OC.Plugin { this.setState({ circles }) }) - this.api.countFolders().then((totalFolders) => { + this.api.countFolders('group').then((totalFolders) => { this.setState({ totalFolders }) }) @@ -223,7 +219,9 @@ export class App extends Component implements OC.Plugin implements OC.Plugin { + this.api.listFolders(0, pageSize + 1, sort, sortOrder === 1 ? 'asc' : 'desc', 'group').then((folders) => { this.setState({ folders, currentPage: 0, @@ -288,52 +286,19 @@ export class App extends Component implements OC.Plugin { - // Tab filter: separate team folders from group folders. - const isTeamSpace = folder.team_circle_id !== null && folder.team_circle_id !== undefined - if (this.state.folderFilter === 'space' && !isTeamSpace) { - return false - } - if (this.state.folderFilter === 'folder' && isTeamSpace) { - return false - } - // Text filter from the global search. - if (this.state.filter === '') { - return true - } - return folder.mount_point.toLowerCase().includes(this.state.filter.toLowerCase()) - }) - .sort((a, b) => a.sortIndex! - b.sortIndex!) + const groupHeader = t('groupfolders', 'Group') + const groupHeaderSort = t('groupfolders', 'Sort by number of groups that have access to this folder') - const rows = filteredFolders + const rows = this.state.folders + .sort((a, b) => a.sortIndex! - b.sortIndex!) .slice(this.state.currentPage * pageSize, this.state.currentPage * pageSize + pageSize) .map(folder => { const id = folder.id - const isTeamSpace = folder.team_circle_id !== null && folder.team_circle_id !== undefined - const teamCircle = isTeamSpace - ? this.state.circles.find(c => c.singleId === folder.team_circle_id) - : undefined + return - {isTeamSpace && ( - - {t('groupfolders', 'Team folder')} - - )} - {this.state.editingMountPoint === id && !isTeamSpace - ? { @@ -342,37 +307,21 @@ export class App extends Component implements OC.Plugin - : isTeamSpace - ? - {folder.mount_point} - - : - } {} : event => { + edit={this.state.editingGroup === id} + showEdit={event => { event.stopPropagation() this.setState({ editingGroup: id }) - }} + }} groups={folder.groups} allCircles={this.state.circles} allGroups={this.state.groups} onAddGroup={this.addGroup.bind(this, folder)} removeGroup={this.removeGroup.bind(this, folder)} onSetPermissions={this.setPermissions.bind(this, folder)} - readOnly={isTeamSpace} + readOnly={false} /> @@ -382,51 +331,30 @@ export class App extends Component implements OC.Plugin - {isTeamSpace ? ( - - {folder.acl ? t('groupfolders', 'Enabled') : t('groupfolders', 'Disabled')} - - ) : ( - <> - this.setAcl(folder, event.target.checked)} - /> - - {folder.acl - && - } - - )} + this.setAcl(folder, event.target.checked)} + /> + + {folder.acl + && + } - {isTeamSpace ? ( - - - - - @@ -525,9 +432,9 @@ export class App extends Component implements OC.Plugin - {filteredFolders.length === 0 + {this.state.folders.length === 0 ? - + : rows} diff --git a/src/types/openapi/openapi.ts b/src/types/openapi/openapi.ts index af2c0b2d0..76ca0a64c 100644 --- a/src/types/openapi/openapi.ts +++ b/src/types/openapi/openapi.ts @@ -483,6 +483,8 @@ export interface operations { order?: "asc" | "desc"; /** @description Only return folders with a given mount point */ mountpoint?: string | null; + /** @description Only return folders of the given type */ + folderType?: "all" | "group" | "team"; }; header: { /** @description Required to be true for the API request to pass */ @@ -1438,7 +1440,10 @@ export interface operations { }; "folder-get-folders-count": { parameters: { - query?: never; + query?: { + /** @description Only count folders of the given type */ + folderType?: "all" | "group" | "team"; + }; header: { /** @description Required to be true for the API request to pass */ "OCS-APIRequest": boolean; @@ -1465,6 +1470,20 @@ export interface operations { }; }; }; + /** @description Invalid folder type */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: unknown; + }; + }; + }; + }; /** @description Current user is not logged in */ 401: { headers: { diff --git a/tests/Folder/FolderManagerTest.php b/tests/Folder/FolderManagerTest.php index 9ee767de3..b7b313312 100644 --- a/tests/Folder/FolderManagerTest.php +++ b/tests/Folder/FolderManagerTest.php @@ -827,6 +827,16 @@ public function testTeamCircleIdIsHydratedAsNullableString(): void { $this->assertTrue($folder->isTeamSpace()); } + public function testCountAllFoldersCanFilterByFolderType(): void { + $this->manager->createFolder('regular-folder'); + $teamFolderId = $this->manager->createFolder('team-folder'); + $this->manager->setTeamCircleId($teamFolderId, 'circle-owner'); + + $this->assertSame(2, $this->manager->countAllFolders()); + $this->assertSame(1, $this->manager->countAllFolders('group')); + $this->assertSame(1, $this->manager->countAllFolders('team')); + } + public function testDeleteCircleKeepsTeamFolderMapping(): void { $classicFolderId = $this->manager->createFolder('classic-folder'); $teamFolderId = $this->manager->createFolder('team-folder');
{emptyMessage}{t('groupfolders', 'No group folders yet')}