Skip to content

Commit bf96030

Browse files
Merge pull request #3808 from nextcloud/feat/pagination
2 parents 55f2b66 + 31647c4 commit bf96030

File tree

6 files changed

+186
-6
lines changed

6 files changed

+186
-6
lines changed

lib/Controller/FolderController.php

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,14 +93,21 @@ private function formatFolder(array $folder): array {
9393
* Gets all Groupfolders
9494
*
9595
* @param bool $applicable Filter by applicable groups
96+
* @param non-negative-int $offset Number of items to skip.
97+
* @param ?positive-int $limit Number of items to return.
9698
* @return DataResponse<Http::STATUS_OK, array<string, GroupFoldersFolder>, array{}>
9799
* @throws OCSNotFoundException Storage not found
100+
* @throws OCSBadRequestException Wrong limit used
98101
*
99102
* 200: Groupfolders returned
100103
*/
101104
#[NoAdminRequired]
102105
#[FrontpageRoute(verb: 'GET', url: '/folders')]
103-
public function getFolders(bool $applicable = false): DataResponse {
106+
public function getFolders(bool $applicable = false, int $offset = 0, ?int $limit = null): DataResponse {
107+
if ($limit !== null && $limit <= 0) {
108+
throw new OCSBadRequestException('The limit must be greater than 0.');
109+
}
110+
104111
$storageId = $this->getRootFolderStorageId();
105112
if ($storageId === null) {
106113
throw new OCSNotFoundException();
@@ -111,8 +118,17 @@ public function getFolders(bool $applicable = false): DataResponse {
111118
// Make them string-indexed for OpenAPI JSON output
112119
$folders[(string)$id] = $this->formatFolder($folder);
113120
}
121+
122+
// Make sure the order is always the same, otherwise pagination could break.
123+
ksort($folders);
124+
114125
$isAdmin = $this->delegationService->isAdminNextcloud() || $this->delegationService->isDelegatedAdmin();
115126
if ($isAdmin && !$applicable) {
127+
// If only the default values are provided the pagination can be skipped.
128+
if ($offset !== 0 || $limit !== null) {
129+
$folders = array_slice($folders, $offset, $limit, true);
130+
}
131+
116132
return new DataResponse($folders);
117133
}
118134

@@ -124,6 +140,11 @@ public function getFolders(bool $applicable = false): DataResponse {
124140
$folders = array_filter(array_map($this->filterNonAdminFolder(...), $folders));
125141
}
126142

143+
// If only the default values are provided the pagination can be skipped.
144+
if ($offset !== 0 || $limit !== null) {
145+
$folders = array_slice($folders, $offset, $limit, true);
146+
}
147+
127148
return new DataResponse($folders);
128149
}
129150

openapi.json

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -472,6 +472,29 @@
472472
]
473473
}
474474
},
475+
{
476+
"name": "offset",
477+
"in": "query",
478+
"description": "Number of items to skip.",
479+
"schema": {
480+
"type": "integer",
481+
"format": "int64",
482+
"default": 0,
483+
"minimum": 0
484+
}
485+
},
486+
{
487+
"name": "limit",
488+
"in": "query",
489+
"description": "Number of items to return.",
490+
"schema": {
491+
"type": "integer",
492+
"format": "int64",
493+
"nullable": true,
494+
"default": null,
495+
"minimum": 1
496+
}
497+
},
475498
{
476499
"name": "OCS-APIRequest",
477500
"in": "header",
@@ -544,6 +567,34 @@
544567
}
545568
}
546569
}
570+
},
571+
"400": {
572+
"description": "Wrong limit used",
573+
"content": {
574+
"application/json": {
575+
"schema": {
576+
"type": "object",
577+
"required": [
578+
"ocs"
579+
],
580+
"properties": {
581+
"ocs": {
582+
"type": "object",
583+
"required": [
584+
"meta",
585+
"data"
586+
],
587+
"properties": {
588+
"meta": {
589+
"$ref": "#/components/schemas/OCSMeta"
590+
},
591+
"data": {}
592+
}
593+
}
594+
}
595+
}
596+
}
597+
}
547598
}
548599
}
549600
},

src/settings/Api.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,13 @@ export class Api {
1515
return generateUrl(`apps/groupfolders/${endpoint}`)
1616
}
1717

18-
async listFolders(): Promise<Folder[]> {
19-
const response = await axios.get<OCSResponse<Folder[]>>(this.getUrl('folders'))
18+
async listFolders(offset = 0, limit?: number): Promise<Folder[]> {
19+
const response = await axios.get<OCSResponse<Folder[]>>(this.getUrl('folders'), {
20+
params: {
21+
offset,
22+
limit,
23+
},
24+
})
2025
return Object.keys(response.data.ocs.data).map(id => response.data.ocs.data[id])
2126
}
2227

src/settings/App.scss

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,17 @@
178178
}
179179
}
180180
}
181-
}
181+
}
182+
183+
.groupfolders-pagination__list {
184+
display: flex;
185+
gap: var(--default-grid-baseline);
186+
justify-content: center;
187+
}
188+
189+
.groupfolders-pagination__button {
190+
height: var(--default-clickable-area);
191+
}
182192

183193
}
184194

src/settings/App.tsx

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ const defaultQuotaOptions = {
2929
Unlimited: -3,
3030
}
3131

32+
const pageSize = 50
33+
3234
export type SortKey = 'mount_point' | 'quota' | 'groups' | 'acl';
3335

3436
export interface AppState {
@@ -46,6 +48,7 @@ export interface AppState {
4648
sortOrder: number;
4749
isAdminNextcloud: boolean;
4850
checkAppsInstalled: boolean;
51+
currentPage: number;
4952
}
5053

5154
export class App extends Component<unknown, AppState> implements OC.Plugin<OC.Search.Core> {
@@ -67,10 +70,12 @@ export class App extends Component<unknown, AppState> implements OC.Plugin<OC.Se
6770
sortOrder: 1,
6871
isAdminNextcloud: false,
6972
checkAppsInstalled: false,
73+
currentPage: 0,
7074
}
7175

7276
componentDidMount() {
73-
this.api.listFolders().then((folders) => {
77+
// list first pageSize + 1 folders so we know if there are more pages
78+
this.api.listFolders(0, pageSize + 1).then((folders) => {
7479
this.setState({ folders })
7580
})
7681
this.api.listGroups().then((groups) => {
@@ -170,6 +175,21 @@ export class App extends Component<unknown, AppState> implements OC.Plugin<OC.Se
170175
this.api.setACL(folder.id, acl)
171176
}
172177

178+
async goToPage(page: number) {
179+
const loadedPage = Math.floor(this.state.folders.length / pageSize)
180+
if (loadedPage <= page) {
181+
const folders = await this.api.listFolders(this.state.folders.length, (page + 1) * pageSize - this.state.folders.length + 1)
182+
this.setState({
183+
folders: [...this.state.folders, ...folders],
184+
currentPage: page,
185+
})
186+
} else {
187+
this.setState({
188+
currentPage: page,
189+
})
190+
}
191+
}
192+
173193
onSortClick = (sort: SortKey) => {
174194
if (this.state.sort === sort) {
175195
this.setState({ sortOrder: -this.state.sortOrder })
@@ -233,11 +253,12 @@ export class App extends Component<unknown, AppState> implements OC.Plugin<OC.Se
233253
if (this.state.filter === '') {
234254
return true
235255
}
236-
return folder.mount_point.toLowerCase().indexOf(this.state.filter.toLowerCase()) !== -1
256+
return folder.mount_point.toLowerCase().includes(this.state.filter.toLowerCase())
237257
}),
238258
identifiers,
239259
direction,
240260
)
261+
.slice(this.state.currentPage * pageSize, this.state.currentPage * pageSize + pageSize)
241262
.map(folder => {
242263
const id = folder.id
243264
return <tr key={id}>
@@ -361,6 +382,60 @@ export class App extends Component<unknown, AppState> implements OC.Plugin<OC.Se
361382
</tr>
362383
</FlipMove>
363384
</table>
385+
<nav className="groupfolders-pagination" aria-label={t('groupfolders', 'Pagination of team folders')}>
386+
<ul className="groupfolders-pagination__list">
387+
<li>
388+
<button
389+
aria-label={t('groupfolders', 'Previous')}
390+
className="groupfolders-pagination__button"
391+
disabled={this.state.currentPage === 0}
392+
title={t('groupfolders', 'Previous')}
393+
onClick={() => this.goToPage(this.state.currentPage - 1)}></button>
394+
</li>
395+
{
396+
// show the "1" button if we are not on the first page
397+
this.state.currentPage > 0 && <li><button onClick={() => this.goToPage(0)}>1</button></li>
398+
}
399+
{
400+
// show the ellipsis button if there are more than 2 pages before the current
401+
this.state.currentPage > 2 && <li><button disabled>&#8230;</button></li>}
402+
{
403+
// show the page right before the current - if there is such a page
404+
this.state.currentPage > 1 && <li><button onClick={() => this.goToPage(this.state.currentPage - 1)}>{this.state.currentPage}</button></li>
405+
}
406+
{ /* the current page as a button */}
407+
<li><button aria-current="page" aria-disabled className="primary">{this.state.currentPage + 1}</button></li>
408+
{
409+
// show the next page if it exists (we know at least that the next exists or not)
410+
(this.state.currentPage + 1) < (this.state.folders.length / pageSize)
411+
&& <li>
412+
<button onClick={() => this.goToPage(this.state.currentPage + 1)}>{this.state.currentPage + 2}</button>
413+
</li>
414+
}
415+
{
416+
// If we know more than two next pages exist we show the ellipsis for the intermediate pages
417+
(this.state.currentPage + 3) < (this.state.folders.length / pageSize)
418+
&& <li>
419+
<button disabled>&#8230;</button>
420+
</li>
421+
}
422+
{
423+
// If more than one next page exist we show the last page as a button
424+
(this.state.currentPage + 2) < (this.state.folders.length / pageSize)
425+
&& <li>
426+
<button onClick={() => this.goToPage(Math.floor(this.state.folders.length / pageSize))}>{Math.floor(this.state.folders.length / pageSize) + 1}</button>
427+
</li>
428+
}
429+
<li>
430+
<button
431+
aria-label={t('groupfolders', 'Next')}
432+
className="groupfolders-pagination__button"
433+
disabled={this.state.currentPage >= Math.floor(this.state.folders.length / pageSize)}
434+
title={t('groupfolders', 'Next')}
435+
onClick={() => this.goToPage(this.state.currentPage + 1)}></button>
436+
</li>
437+
</ul>
438+
</nav>
364439
</div>
365440
}
366441

src/types/openapi/openapi.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -410,6 +410,10 @@ export interface operations {
410410
query?: {
411411
/** @description Filter by applicable groups */
412412
applicable?: 0 | 1;
413+
/** @description Number of items to skip. */
414+
offset?: number;
415+
/** @description Number of items to return. */
416+
limit?: number | null;
413417
};
414418
header: {
415419
/** @description Required to be true for the API request to pass */
@@ -436,6 +440,20 @@ export interface operations {
436440
};
437441
};
438442
};
443+
/** @description Wrong limit used */
444+
400: {
445+
headers: {
446+
[name: string]: unknown;
447+
};
448+
content: {
449+
"application/json": {
450+
ocs: {
451+
meta: components["schemas"]["OCSMeta"];
452+
data: unknown;
453+
};
454+
};
455+
};
456+
};
439457
/** @description Storage not found */
440458
404: {
441459
headers: {

0 commit comments

Comments
 (0)