@openmrs/esm-react-utils 10.0.1-pre.5295 → 10.0.1-pre.5299
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/dist/useFhirPagination.d.ts +1 -1
- package/dist/useOpenmrsPagination.d.ts +2 -2
- package/dist/useOpenmrsPagination.d.ts.map +1 -1
- package/dist/useOpenmrsPagination.js +66 -15
- package/dist/usePagination.d.ts +5 -0
- package/dist/usePagination.d.ts.map +1 -1
- package/dist/usePagination.js +23 -21
- package/package.json +23 -23
- package/src/useOpenmrsPagination.test.ts +297 -1
- package/src/useOpenmrsPagination.ts +72 -10
- package/src/usePagination.test.tsx +315 -0
- package/src/usePagination.ts +27 -15
package/.turbo/turbo-build.log
CHANGED
|
@@ -28,7 +28,7 @@ export declare function useFhirPagination<T extends fhir.ResourceBase>(url: stri
|
|
|
28
28
|
totalPages: number;
|
|
29
29
|
totalCount: number;
|
|
30
30
|
currentPage: number;
|
|
31
|
-
currentPageSize:
|
|
31
|
+
currentPageSize: number;
|
|
32
32
|
paginated: boolean;
|
|
33
33
|
showNextButton: boolean;
|
|
34
34
|
showPreviousButton: boolean;
|
|
@@ -61,7 +61,7 @@ export declare function useOpenmrsPagination<T>(url: string | URL, pageSize: num
|
|
|
61
61
|
totalPages: number;
|
|
62
62
|
totalCount: number;
|
|
63
63
|
currentPage: number;
|
|
64
|
-
currentPageSize:
|
|
64
|
+
currentPageSize: number;
|
|
65
65
|
paginated: boolean;
|
|
66
66
|
showNextButton: boolean;
|
|
67
67
|
showPreviousButton: boolean;
|
|
@@ -87,7 +87,7 @@ export declare function useServerPagination<T, R>(url: string | URL, pageSize: n
|
|
|
87
87
|
totalPages: number;
|
|
88
88
|
totalCount: number;
|
|
89
89
|
currentPage: number;
|
|
90
|
-
currentPageSize:
|
|
90
|
+
currentPageSize: number;
|
|
91
91
|
paginated: boolean;
|
|
92
92
|
showNextButton: boolean;
|
|
93
93
|
showPreviousButton: boolean;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useOpenmrsPagination.d.ts","sourceRoot":"","sources":["../src/useOpenmrsPagination.ts"],"names":[],"mappings":"AAAA,2BAA2B;AAC3B,OAAO,EAAE,KAAK,aAAa,EAAyB,MAAM,kBAAkB,CAAC;AAE7E,OAAe,EAAE,KAAK,gBAAgB,EAAE,MAAM,KAAK,CAAC;AAGpD,MAAM,WAAW,wBAAwB,CAAC,CAAC;IACzC,OAAO,EAAE,KAAK,CAAC,CAAC,CAAC,CAAC;IAClB,KAAK,EAAE,KAAK,CAAC;QAAE,GAAG,EAAE,MAAM,GAAG,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACpD,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,0BAA0B,CAAC,CAAC;IAC3C;;OAEG;IACH,SAAS,CAAC,EAAE,OAAO,CAAC;IAEpB;;OAEG;IACH,OAAO,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC,aAAa,CAAC,CAAC,CAAC,CAAC,CAAC;IAErD;;OAEG;IACH,SAAS,CAAC,EAAE,gBAAgB,CAAC;CAC9B;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,wBAAgB,oBAAoB,CAAC,CAAC,EACpC,GAAG,EAAE,MAAM,GAAG,GAAG,EACjB,QAAQ,EAAE,MAAM,EAChB,OAAO,GAAE,0BAA0B,CAAC,wBAAwB,CAAC,CAAC,CAAC,CAAM;;;;;;;;;;;;;
|
|
1
|
+
{"version":3,"file":"useOpenmrsPagination.d.ts","sourceRoot":"","sources":["../src/useOpenmrsPagination.ts"],"names":[],"mappings":"AAAA,2BAA2B;AAC3B,OAAO,EAAE,KAAK,aAAa,EAAyB,MAAM,kBAAkB,CAAC;AAE7E,OAAe,EAAE,KAAK,gBAAgB,EAAE,MAAM,KAAK,CAAC;AAGpD,MAAM,WAAW,wBAAwB,CAAC,CAAC;IACzC,OAAO,EAAE,KAAK,CAAC,CAAC,CAAC,CAAC;IAClB,KAAK,EAAE,KAAK,CAAC;QAAE,GAAG,EAAE,MAAM,GAAG,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACpD,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,0BAA0B,CAAC,CAAC;IAC3C;;OAEG;IACH,SAAS,CAAC,EAAE,OAAO,CAAC;IAEpB;;OAEG;IACH,OAAO,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC,aAAa,CAAC,CAAC,CAAC,CAAC,CAAC;IAErD;;OAEG;IACH,SAAS,CAAC,EAAE,gBAAgB,CAAC;CAC9B;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,wBAAgB,oBAAoB,CAAC,CAAC,EACpC,GAAG,EAAE,MAAM,GAAG,GAAG,EACjB,QAAQ,EAAE,MAAM,EAChB,OAAO,GAAE,0BAA0B,CAAC,wBAAwB,CAAC,CAAC,CAAC,CAAM;;;;;;;;;;;;;iBAyG5D,MAAM;;;EAtGhB;AAED,KAAK,+BAA+B,CAAC,CAAC,IAAI,wBAAwB,CAAC,CAAC,EAAE,wBAAwB,CAAC,CAAC,CAAC,CAAC,CAAC;AACnG,eAAO,MAAM,+BAA+B,EAAE,+BAA+B,CAAC,GAAG,CA6BhF,CAAC;AAEF,MAAM,WAAW,wBAAwB,CAAC,CAAC,EAAE,CAAC;IAC5C,eAAe,EAAE,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,EAAE,KAAK,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,KAAK,MAAM,GAAG,IAAI,CAAC;IACzF,UAAU,EAAE,CAAC,QAAQ,EAAE,CAAC,GAAG,SAAS,KAAK,MAAM,GAAG,IAAI,CAAC;IACvD,aAAa,EAAE,CAAC,QAAQ,EAAE,CAAC,GAAG,SAAS,KAAK,MAAM,CAAC;IACnD,kBAAkB,EAAE,CAAC,QAAQ,EAAE,CAAC,GAAG,SAAS,KAAK,MAAM,CAAC;IACxD,OAAO,EAAE,CAAC,QAAQ,EAAE,CAAC,GAAG,SAAS,KAAK,KAAK,CAAC,CAAC,CAAC,GAAG,SAAS,CAAC;CAC5D;AAED,wBAAgB,mBAAmB,CAAC,CAAC,EAAE,CAAC,EACtC,GAAG,EAAE,MAAM,GAAG,GAAG,EACjB,QAAQ,EAAE,MAAM,EAChB,wBAAwB,EAAE,wBAAwB,CAAC,CAAC,EAAE,CAAC,CAAC,EACxD,OAAO,GAAE,0BAA0B,CAAC,CAAC,CAAM;;;;;;;;;;;;;iBAwDlC,MAAM;;;EAoEhB"}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** @module @category UI */ import { makeUrl, openmrsFetch } from "@openmrs/esm-api";
|
|
2
|
-
import { useCallback, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
3
3
|
import useSWR from "swr";
|
|
4
4
|
import useSWRImmutable from "swr/immutable";
|
|
5
5
|
/**
|
|
@@ -65,12 +65,31 @@ export function useServerPagination(url, pageSize, serverPaginationHandlers, opt
|
|
|
65
65
|
const { getPaginatedUrl, getTotalCount, getCurrentPageSize, getData } = serverPaginationHandlers;
|
|
66
66
|
const { immutable, swrConfig } = options;
|
|
67
67
|
const fetcher = options.fetcher ?? openmrsFetch;
|
|
68
|
-
|
|
68
|
+
// 1-indexing instead of 0-indexing, to keep consistency with `usePagination`
|
|
69
|
+
const [currentPage, setCurrentPage] = useState(1);
|
|
69
70
|
// Cache the totalCount and currentPageSize so we don't lose them
|
|
70
71
|
// as we wait for next page's result to load while navigating to a different page.
|
|
71
72
|
// This can be used to prevent jarring UI changes while loading
|
|
72
73
|
const totalCount = useRef(Number.NaN);
|
|
73
|
-
|
|
74
|
+
// this value is usually same as pageSize, except when currentPage is last page.
|
|
75
|
+
const currentPageSize = useRef(Number.NaN);
|
|
76
|
+
// tracked as a ref so we don't need to make the current page a dependency of `goTo()`, etc.
|
|
77
|
+
const currentPageRef = useRef(currentPage);
|
|
78
|
+
// use to differentiate "endpoint doesn't return a total" from "we haven't run a request yet"
|
|
79
|
+
const hasResponded = useRef(false);
|
|
80
|
+
const warnedMissingTotal = useRef(false);
|
|
81
|
+
// if the URL we're querying changes, reset to page 1
|
|
82
|
+
const urlKey = url?.toString() ?? '';
|
|
83
|
+
const [lastUrlKey, setLastUrlKey] = useState(urlKey);
|
|
84
|
+
if (urlKey !== lastUrlKey) {
|
|
85
|
+
setLastUrlKey(urlKey);
|
|
86
|
+
setCurrentPage(1);
|
|
87
|
+
currentPageRef.current = 1;
|
|
88
|
+
totalCount.current = Number.NaN;
|
|
89
|
+
currentPageSize.current = Number.NaN;
|
|
90
|
+
hasResponded.current = false;
|
|
91
|
+
warnedMissingTotal.current = false;
|
|
92
|
+
}
|
|
74
93
|
const limit = pageSize;
|
|
75
94
|
const startIndex = (currentPage - 1) * pageSize;
|
|
76
95
|
const urlUrl = useMemo(()=>{
|
|
@@ -85,39 +104,71 @@ export function useServerPagination(url, pageSize, serverPaginationHandlers, opt
|
|
|
85
104
|
if (data?.data) {
|
|
86
105
|
totalCount.current = getTotalCount(data?.data);
|
|
87
106
|
currentPageSize.current = getCurrentPageSize(data?.data);
|
|
107
|
+
hasResponded.current = true;
|
|
88
108
|
}
|
|
89
109
|
const totalPages = Math.ceil(totalCount.current / pageSize);
|
|
110
|
+
// The bound is read from `totalCount` at call time rather than captured from the creating render,
|
|
111
|
+
// which is what keeps this callback referentially stable, apart from a change in `pageSize`.
|
|
90
112
|
const goTo = useCallback((page)=>{
|
|
91
|
-
if (
|
|
113
|
+
if (!Number.isInteger(page) || page < 1) {
|
|
114
|
+
console.warn(`useServerPagination: ignoring goTo(${page}); page must be a positive integer.`);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
// page count is not known prior to running any query when `bound` will be `NaN`, so in this
|
|
118
|
+
// case we assume the requested page is fine; after results return, things will settle properly
|
|
119
|
+
// clamped
|
|
120
|
+
const bound = Math.ceil(totalCount.current / pageSize);
|
|
121
|
+
if (!hasResponded.current || Number.isNaN(bound) || page <= bound) {
|
|
122
|
+
currentPageRef.current = page;
|
|
92
123
|
setCurrentPage(page);
|
|
93
124
|
} else {
|
|
94
125
|
console.warn('Invalid attempt to go to out of bounds page: ' + page);
|
|
95
126
|
}
|
|
96
127
|
}, [
|
|
97
|
-
|
|
128
|
+
pageSize
|
|
129
|
+
]);
|
|
130
|
+
// A page accepted before the total was known may turn out not to exist, so put the user on the last
|
|
131
|
+
// real page once the response settles it. Costs an extra request, but only for a page that was out of
|
|
132
|
+
// range to begin with; without it the hook sits on an empty page with no way back.
|
|
133
|
+
useEffect(()=>{
|
|
134
|
+
if (!hasResponded.current) {
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if (Number.isNaN(totalCount.current)) {
|
|
138
|
+
if (!warnedMissingTotal.current) {
|
|
139
|
+
warnedMissingTotal.current = true;
|
|
140
|
+
console.warn(`useServerPagination: ${urlKey} returned no total count, so out-of-range pages cannot be detected.`);
|
|
141
|
+
}
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const lastPage = Math.max(1, Math.ceil(totalCount.current / pageSize));
|
|
145
|
+
if (currentPage > lastPage) {
|
|
146
|
+
console.warn(`Invalid attempt to go to out of bounds page: ${currentPage}; returning to page ${lastPage}.`);
|
|
147
|
+
currentPageRef.current = lastPage;
|
|
148
|
+
setCurrentPage(lastPage);
|
|
149
|
+
}
|
|
150
|
+
}, [
|
|
98
151
|
currentPage,
|
|
99
|
-
|
|
152
|
+
pageSize,
|
|
153
|
+
urlKey,
|
|
154
|
+
data
|
|
100
155
|
]);
|
|
101
156
|
const goToNext = useCallback(()=>{
|
|
102
|
-
goTo(
|
|
157
|
+
goTo(currentPageRef.current + 1);
|
|
103
158
|
}, [
|
|
104
|
-
|
|
105
|
-
currentPage,
|
|
106
|
-
totalPages
|
|
159
|
+
goTo
|
|
107
160
|
]);
|
|
108
161
|
const goToPrevious = useCallback(()=>{
|
|
109
|
-
goTo(
|
|
162
|
+
goTo(currentPageRef.current - 1);
|
|
110
163
|
}, [
|
|
111
|
-
|
|
112
|
-
currentPage,
|
|
113
|
-
totalPages
|
|
164
|
+
goTo
|
|
114
165
|
]);
|
|
115
166
|
return {
|
|
116
167
|
data: getData(data?.data),
|
|
117
168
|
totalPages,
|
|
118
169
|
totalCount: totalCount.current,
|
|
119
170
|
currentPage,
|
|
120
|
-
currentPageSize,
|
|
171
|
+
currentPageSize: currentPageSize.current,
|
|
121
172
|
paginated: totalPages > 1,
|
|
122
173
|
showNextButton: currentPage < totalPages,
|
|
123
174
|
showPreviousButton: currentPage > 1,
|
package/dist/usePagination.d.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Use this hook to paginate data that already exists on the client side.
|
|
3
3
|
* Note that if the data is obtained from server-side, the caller must handle server-side pagination manually.
|
|
4
|
+
*
|
|
5
|
+
* `goTo`, `goToNext` and `goToPrevious` keep the same identity for the life of the component, so they are
|
|
6
|
+
* safe to list in a dependency array. `currentPage` is bounded by the data, so it can change on its own
|
|
7
|
+
* when the data shrinks; a component reacting to `currentPage` should expect that.
|
|
8
|
+
*
|
|
4
9
|
* @see `useServerPagination` for hook that automatically manages server-side pagination.
|
|
5
10
|
* @see `useServerInfinite` for hook to get all data loaded onto the client-side
|
|
6
11
|
* @param data
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"usePagination.d.ts","sourceRoot":"","sources":["../src/usePagination.ts"],"names":[],"mappings":"AAKA
|
|
1
|
+
{"version":3,"file":"usePagination.d.ts","sourceRoot":"","sources":["../src/usePagination.ts"],"names":[],"mappings":"AAKA;;;;;;;;;;;;;GAaG;AACH,wBAAgB,aAAa,CAAC,CAAC,EAAE,IAAI,GAAE,KAAK,CAAC,CAAC,CAAM,EAAE,cAAc,SAAwB;;;;;;;iBAwB1D,MAAM;;;EAiCvC"}
|
package/dist/usePagination.js
CHANGED
|
@@ -3,17 +3,28 @@ const defaultResultsPerPage = 10;
|
|
|
3
3
|
/**
|
|
4
4
|
* Use this hook to paginate data that already exists on the client side.
|
|
5
5
|
* Note that if the data is obtained from server-side, the caller must handle server-side pagination manually.
|
|
6
|
+
*
|
|
7
|
+
* `goTo`, `goToNext` and `goToPrevious` keep the same identity for the life of the component, so they are
|
|
8
|
+
* safe to list in a dependency array. `currentPage` is bounded by the data, so it can change on its own
|
|
9
|
+
* when the data shrinks; a component reacting to `currentPage` should expect that.
|
|
10
|
+
*
|
|
6
11
|
* @see `useServerPagination` for hook that automatically manages server-side pagination.
|
|
7
12
|
* @see `useServerInfinite` for hook to get all data loaded onto the client-side
|
|
8
13
|
* @param data
|
|
9
14
|
* @param resultsPerPage
|
|
10
15
|
* @returns
|
|
11
16
|
*/ export function usePagination(data = [], resultsPerPage = defaultResultsPerPage) {
|
|
12
|
-
const [
|
|
17
|
+
const [requestedPage, setRequestedPage] = useState(1);
|
|
13
18
|
const totalPages = useMemo(()=>typeof resultsPerPage === 'number' && resultsPerPage > 0 ? Math.max(1, Math.ceil(data.length / resultsPerPage)) : 1, [
|
|
14
19
|
data.length,
|
|
15
20
|
resultsPerPage
|
|
16
21
|
]);
|
|
22
|
+
// If requested page is past the end, set to the end. Done here to retain referential stability
|
|
23
|
+
// on the functions returned by this hook
|
|
24
|
+
if (requestedPage > totalPages) {
|
|
25
|
+
setRequestedPage(totalPages);
|
|
26
|
+
}
|
|
27
|
+
const page = Math.min(Math.max(1, requestedPage), totalPages);
|
|
17
28
|
const results = useMemo(()=>{
|
|
18
29
|
const lowerBound = (page - 1) * resultsPerPage;
|
|
19
30
|
const upperBound = (page + 0) * resultsPerPage;
|
|
@@ -24,28 +35,19 @@ const defaultResultsPerPage = 10;
|
|
|
24
35
|
resultsPerPage
|
|
25
36
|
]);
|
|
26
37
|
const goTo = useCallback((page)=>{
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
totalPages
|
|
31
|
-
]);
|
|
32
|
-
const goToNext = useCallback(()=>{
|
|
33
|
-
if (page < totalPages) {
|
|
34
|
-
setPage(page + 1);
|
|
38
|
+
if (!Number.isInteger(page) || page < 1) {
|
|
39
|
+
console.warn(`usePagination: ignoring goTo(${page}); page must be a positive integer.`);
|
|
40
|
+
return;
|
|
35
41
|
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
42
|
+
setRequestedPage(page);
|
|
43
|
+
}, []);
|
|
44
|
+
// overflow clamping happens in render; see the setRequestedPage() call above
|
|
45
|
+
const goToNext = useCallback(()=>{
|
|
46
|
+
setRequestedPage((p)=>p + 1);
|
|
47
|
+
}, []);
|
|
41
48
|
const goToPrevious = useCallback(()=>{
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
}
|
|
45
|
-
}, [
|
|
46
|
-
page,
|
|
47
|
-
setPage
|
|
48
|
-
]);
|
|
49
|
+
setRequestedPage((p)=>Math.max(1, p - 1));
|
|
50
|
+
}, []);
|
|
49
51
|
const memoisedPaginatedData = useMemo(()=>({
|
|
50
52
|
results,
|
|
51
53
|
totalPages,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openmrs/esm-react-utils",
|
|
3
|
-
"version": "10.0.1-pre.
|
|
3
|
+
"version": "10.0.1-pre.5299",
|
|
4
4
|
"license": "MPL-2.0",
|
|
5
5
|
"description": "React utilities for OpenMRS.",
|
|
6
6
|
"type": "module",
|
|
@@ -60,17 +60,17 @@
|
|
|
60
60
|
"single-spa-react": "^6.0.2"
|
|
61
61
|
},
|
|
62
62
|
"peerDependencies": {
|
|
63
|
-
"@openmrs/esm-api": "^10.0.1-pre.
|
|
64
|
-
"@openmrs/esm-config": "^10.0.1-pre.
|
|
65
|
-
"@openmrs/esm-context": "^10.0.1-pre.
|
|
66
|
-
"@openmrs/esm-emr-api": "^10.0.1-pre.
|
|
67
|
-
"@openmrs/esm-error-handling": "^10.0.1-pre.
|
|
68
|
-
"@openmrs/esm-extensions": "^10.0.1-pre.
|
|
69
|
-
"@openmrs/esm-feature-flags": "^10.0.1-pre.
|
|
70
|
-
"@openmrs/esm-globals": "^10.0.1-pre.
|
|
71
|
-
"@openmrs/esm-navigation": "^10.0.1-pre.
|
|
72
|
-
"@openmrs/esm-state": "^10.0.1-pre.
|
|
73
|
-
"@openmrs/esm-utils": "^10.0.1-pre.
|
|
63
|
+
"@openmrs/esm-api": "^10.0.1-pre.5299",
|
|
64
|
+
"@openmrs/esm-config": "^10.0.1-pre.5299",
|
|
65
|
+
"@openmrs/esm-context": "^10.0.1-pre.5299",
|
|
66
|
+
"@openmrs/esm-emr-api": "^10.0.1-pre.5299",
|
|
67
|
+
"@openmrs/esm-error-handling": "^10.0.1-pre.5299",
|
|
68
|
+
"@openmrs/esm-extensions": "^10.0.1-pre.5299",
|
|
69
|
+
"@openmrs/esm-feature-flags": "^10.0.1-pre.5299",
|
|
70
|
+
"@openmrs/esm-globals": "^10.0.1-pre.5299",
|
|
71
|
+
"@openmrs/esm-navigation": "^10.0.1-pre.5299",
|
|
72
|
+
"@openmrs/esm-state": "^10.0.1-pre.5299",
|
|
73
|
+
"@openmrs/esm-utils": "^10.0.1-pre.5299",
|
|
74
74
|
"dayjs": "1.x",
|
|
75
75
|
"i18next": "25.x",
|
|
76
76
|
"react": "18.x",
|
|
@@ -80,17 +80,17 @@
|
|
|
80
80
|
"swr": "2.x"
|
|
81
81
|
},
|
|
82
82
|
"devDependencies": {
|
|
83
|
-
"@openmrs/esm-api": "10.0.1-pre.
|
|
84
|
-
"@openmrs/esm-config": "10.0.1-pre.
|
|
85
|
-
"@openmrs/esm-context": "10.0.1-pre.
|
|
86
|
-
"@openmrs/esm-emr-api": "10.0.1-pre.
|
|
87
|
-
"@openmrs/esm-error-handling": "10.0.1-pre.
|
|
88
|
-
"@openmrs/esm-extensions": "10.0.1-pre.
|
|
89
|
-
"@openmrs/esm-feature-flags": "10.0.1-pre.
|
|
90
|
-
"@openmrs/esm-globals": "10.0.1-pre.
|
|
91
|
-
"@openmrs/esm-navigation": "10.0.1-pre.
|
|
92
|
-
"@openmrs/esm-state": "10.0.1-pre.
|
|
93
|
-
"@openmrs/esm-utils": "10.0.1-pre.
|
|
83
|
+
"@openmrs/esm-api": "10.0.1-pre.5299",
|
|
84
|
+
"@openmrs/esm-config": "10.0.1-pre.5299",
|
|
85
|
+
"@openmrs/esm-context": "10.0.1-pre.5299",
|
|
86
|
+
"@openmrs/esm-emr-api": "10.0.1-pre.5299",
|
|
87
|
+
"@openmrs/esm-error-handling": "10.0.1-pre.5299",
|
|
88
|
+
"@openmrs/esm-extensions": "10.0.1-pre.5299",
|
|
89
|
+
"@openmrs/esm-feature-flags": "10.0.1-pre.5299",
|
|
90
|
+
"@openmrs/esm-globals": "10.0.1-pre.5299",
|
|
91
|
+
"@openmrs/esm-navigation": "10.0.1-pre.5299",
|
|
92
|
+
"@openmrs/esm-state": "10.0.1-pre.5299",
|
|
93
|
+
"@openmrs/esm-utils": "10.0.1-pre.5299",
|
|
94
94
|
"@swc/cli": "0.8.1",
|
|
95
95
|
"@swc/core": "1.15.21",
|
|
96
96
|
"@vitest/coverage-v8": "^4.1.2",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
2
|
import { renderHook, waitFor, act } from '@testing-library/react';
|
|
3
3
|
import { useOpenmrsPagination, type OpenMRSPaginatedResponse } from './useOpenmrsPagination';
|
|
4
4
|
|
|
@@ -25,6 +25,11 @@ export async function getTestData(url: string, totalCount: number): Promise<Open
|
|
|
25
25
|
return { results, links, totalCount } as OpenMRSPaginatedResponse<number>;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/** Whether any warning mentions `page`, so the exact wording stays free to change. */
|
|
29
|
+
function warned(warn: { mock: { calls: Array<Array<unknown>> } }, page: string) {
|
|
30
|
+
return warn.mock.calls.some((call) => String(call[0]).includes(page));
|
|
31
|
+
}
|
|
32
|
+
|
|
28
33
|
describe('useOpenmrsPagination', () => {
|
|
29
34
|
it('should not fetch anything if url is null', async () => {
|
|
30
35
|
const { result } = renderHook(() =>
|
|
@@ -53,6 +58,24 @@ describe('useOpenmrsPagination', () => {
|
|
|
53
58
|
expect(result.current.data).toEqual(getIntArray(0, 17));
|
|
54
59
|
});
|
|
55
60
|
|
|
61
|
+
it('reports currentPageSize as a number, matching the rows on the current page', async () => {
|
|
62
|
+
const pageSize = 20;
|
|
63
|
+
const expectedRowCount = 50;
|
|
64
|
+
const { result } = renderHook(() =>
|
|
65
|
+
useOpenmrsPagination('http://localhost/page-size', pageSize, {
|
|
66
|
+
fetcher: (url) => getTestData(url, expectedRowCount).then((data) => ({ data }) as any),
|
|
67
|
+
}),
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
await waitFor(() => expect(result.current.isLoading).toBeFalsy());
|
|
71
|
+
expect(result.current.currentPageSize).toBe(20);
|
|
72
|
+
|
|
73
|
+
// the last page is short, and the reported size follows it
|
|
74
|
+
act(() => result.current.goTo(3));
|
|
75
|
+
await waitFor(() => expect(result.current.currentPage).toBe(3));
|
|
76
|
+
await waitFor(() => expect(result.current.currentPageSize).toBe(10));
|
|
77
|
+
});
|
|
78
|
+
|
|
56
79
|
it('should fetch 2 pages if pageSize < number of rows <= 2 * pageSize', async () => {
|
|
57
80
|
const pageSize = 20;
|
|
58
81
|
const expectedRowCount = 40;
|
|
@@ -134,4 +157,277 @@ describe('useOpenmrsPagination', () => {
|
|
|
134
157
|
expect(result.current.totalCount).toBe(expectedRowCount);
|
|
135
158
|
expect(result.current.data).toEqual(getIntArray(1320, 17));
|
|
136
159
|
});
|
|
160
|
+
|
|
161
|
+
describe('callback identity', () => {
|
|
162
|
+
it('keeps the same callbacks when the total count arrives and when the page changes', async () => {
|
|
163
|
+
const { result } = renderHook(() =>
|
|
164
|
+
useOpenmrsPagination('http://localhost/identity', 20, {
|
|
165
|
+
fetcher: (url) => getTestData(url, 1337).then((data) => ({ data }) as any),
|
|
166
|
+
}),
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
// captured before any response, while totalCount is still NaN
|
|
170
|
+
const { goTo, goToNext, goToPrevious } = result.current;
|
|
171
|
+
expect(result.current.totalPages).toBeNaN();
|
|
172
|
+
|
|
173
|
+
await waitFor(() => expect(result.current.isLoading).toBeFalsy());
|
|
174
|
+
expect(result.current.totalPages).toBe(67);
|
|
175
|
+
|
|
176
|
+
act(() => result.current.goTo(4));
|
|
177
|
+
await waitFor(() => expect(result.current.currentPage).toBe(4));
|
|
178
|
+
|
|
179
|
+
act(() => result.current.goToNext());
|
|
180
|
+
await waitFor(() => expect(result.current.currentPage).toBe(5));
|
|
181
|
+
|
|
182
|
+
expect(result.current.goTo).toBe(goTo);
|
|
183
|
+
expect(result.current.goToNext).toBe(goToNext);
|
|
184
|
+
expect(result.current.goToPrevious).toBe(goToPrevious);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe('out of bounds navigation', () => {
|
|
189
|
+
it('warns, stays put and issues no further request', async () => {
|
|
190
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
191
|
+
const fetcher = vi.fn((url: string) => getTestData(url, 40).then((data) => ({ data }) as any));
|
|
192
|
+
|
|
193
|
+
const { result } = renderHook(() => useOpenmrsPagination('http://localhost/bounds', 20, { fetcher }));
|
|
194
|
+
|
|
195
|
+
await waitFor(() => expect(result.current.isLoading).toBeFalsy());
|
|
196
|
+
expect(result.current.totalPages).toBe(2);
|
|
197
|
+
|
|
198
|
+
const fetchCount = fetcher.mock.calls.length;
|
|
199
|
+
|
|
200
|
+
act(() => result.current.goTo(999));
|
|
201
|
+
act(() => result.current.goTo(0));
|
|
202
|
+
|
|
203
|
+
expect(result.current.currentPage).toBe(1);
|
|
204
|
+
// asserted as substrings so the wording can be improved without breaking these
|
|
205
|
+
expect(warned(warn, '999')).toBe(true);
|
|
206
|
+
expect(warned(warn, '0')).toBe(true);
|
|
207
|
+
expect(fetcher.mock.calls.length).toBe(fetchCount);
|
|
208
|
+
|
|
209
|
+
warn.mockRestore();
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('warns when going to the previous page from the first page', async () => {
|
|
213
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
214
|
+
|
|
215
|
+
const { result } = renderHook(() =>
|
|
216
|
+
useOpenmrsPagination('http://localhost/previous', 20, {
|
|
217
|
+
fetcher: (url) => getTestData(url, 40).then((data) => ({ data }) as any),
|
|
218
|
+
}),
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
await waitFor(() => expect(result.current.isLoading).toBeFalsy());
|
|
222
|
+
|
|
223
|
+
act(() => result.current.goToPrevious());
|
|
224
|
+
|
|
225
|
+
expect(result.current.currentPage).toBe(1);
|
|
226
|
+
expect(warned(warn, '0')).toBe(true);
|
|
227
|
+
|
|
228
|
+
warn.mockRestore();
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
describe('stepping', () => {
|
|
233
|
+
// Batching the two calls into one commit is what distinguishes stepping from the page as it stands
|
|
234
|
+
// from stepping from a page captured when the callback was created; the latter is a commit behind.
|
|
235
|
+
it('steps from the latest requested page when batched with a goTo', async () => {
|
|
236
|
+
const { result } = renderHook(() =>
|
|
237
|
+
useOpenmrsPagination('http://localhost/batched-next', 20, {
|
|
238
|
+
fetcher: (url) => getTestData(url, 200).then((data) => ({ data }) as any),
|
|
239
|
+
}),
|
|
240
|
+
);
|
|
241
|
+
await waitFor(() => expect(result.current.isLoading).toBeFalsy());
|
|
242
|
+
|
|
243
|
+
const { goTo, goToNext } = result.current;
|
|
244
|
+
act(() => {
|
|
245
|
+
goTo(3);
|
|
246
|
+
goToNext();
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
await waitFor(() => expect(result.current.isValidating).toBeFalsy());
|
|
250
|
+
expect(result.current.currentPage).toBe(4);
|
|
251
|
+
expect(result.current.data).toEqual(getIntArray(60, 20));
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('steps back from the latest requested page when batched with a goTo', async () => {
|
|
255
|
+
const { result } = renderHook(() =>
|
|
256
|
+
useOpenmrsPagination('http://localhost/batched-prev', 20, {
|
|
257
|
+
fetcher: (url) => getTestData(url, 200).then((data) => ({ data }) as any),
|
|
258
|
+
}),
|
|
259
|
+
);
|
|
260
|
+
await waitFor(() => expect(result.current.isLoading).toBeFalsy());
|
|
261
|
+
|
|
262
|
+
const { goTo, goToPrevious } = result.current;
|
|
263
|
+
act(() => {
|
|
264
|
+
goTo(5);
|
|
265
|
+
goToPrevious();
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
await waitFor(() => expect(result.current.isValidating).toBeFalsy());
|
|
269
|
+
expect(result.current.currentPage).toBe(4);
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
describe('changing the url', () => {
|
|
274
|
+
it('returns to page 1 and does not request the old offset against the new url', async () => {
|
|
275
|
+
const urls: Array<string> = [];
|
|
276
|
+
const fetcher = (url: string) => {
|
|
277
|
+
urls.push(url);
|
|
278
|
+
return getTestData(url, url.includes('wide') ? 1337 : 10).then((data) => ({ data }) as any);
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
const { result, rerender } = renderHook(({ url }) => useOpenmrsPagination(url, 20, { fetcher }), {
|
|
282
|
+
initialProps: { url: 'http://localhost/search?q=wide' },
|
|
283
|
+
});
|
|
284
|
+
await waitFor(() => expect(result.current.isLoading).toBeFalsy());
|
|
285
|
+
|
|
286
|
+
act(() => result.current.goTo(5));
|
|
287
|
+
await waitFor(() => expect(result.current.currentPage).toBe(5));
|
|
288
|
+
|
|
289
|
+
rerender({ url: 'http://localhost/search?q=narrow' });
|
|
290
|
+
await waitFor(() => expect(result.current.isValidating).toBeFalsy());
|
|
291
|
+
|
|
292
|
+
expect(result.current.currentPage).toBe(1);
|
|
293
|
+
expect(result.current.totalCount).toBe(10);
|
|
294
|
+
expect(result.current.data).toEqual(getIntArray(0, 10));
|
|
295
|
+
expect(urls.filter((u) => u.includes('narrow') && u.includes('startIndex=80'))).toEqual([]);
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
describe('navigation before the first response', () => {
|
|
300
|
+
it('applies a page requested while the total count is still unknown', async () => {
|
|
301
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
302
|
+
|
|
303
|
+
let release: () => void;
|
|
304
|
+
const firstResponse = new Promise<void>((resolve) => {
|
|
305
|
+
release = resolve;
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
const { result } = renderHook(() =>
|
|
309
|
+
useOpenmrsPagination('http://localhost/race', 20, {
|
|
310
|
+
fetcher: async (url) => {
|
|
311
|
+
await firstResponse;
|
|
312
|
+
return { data: await getTestData(url, 1337) } as any;
|
|
313
|
+
},
|
|
314
|
+
}),
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
expect(result.current.totalCount).toBeNaN();
|
|
318
|
+
expect(result.current.totalPages).toBeNaN();
|
|
319
|
+
|
|
320
|
+
act(() => result.current.goTo(2));
|
|
321
|
+
expect(result.current.currentPage).toBe(2);
|
|
322
|
+
expect(warn).not.toHaveBeenCalled();
|
|
323
|
+
|
|
324
|
+
release!();
|
|
325
|
+
|
|
326
|
+
await waitFor(() => expect(result.current.data).toBeDefined());
|
|
327
|
+
await waitFor(() => expect(result.current.isLoading).toBeFalsy());
|
|
328
|
+
|
|
329
|
+
expect(result.current.currentPage).toBe(2);
|
|
330
|
+
expect(result.current.totalPages).toBe(67);
|
|
331
|
+
expect(result.current.data).toEqual(getIntArray(20, 20));
|
|
332
|
+
|
|
333
|
+
warn.mockRestore();
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it('returns to the last real page when the requested one turns out not to exist', async () => {
|
|
337
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
338
|
+
|
|
339
|
+
let release: () => void;
|
|
340
|
+
const firstResponse = new Promise<void>((resolve) => {
|
|
341
|
+
release = resolve;
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
const { result } = renderHook(() =>
|
|
345
|
+
useOpenmrsPagination('http://localhost/strand', 20, {
|
|
346
|
+
fetcher: async (url) => {
|
|
347
|
+
await firstResponse;
|
|
348
|
+
return { data: await getTestData(url, 40) } as any;
|
|
349
|
+
},
|
|
350
|
+
}),
|
|
351
|
+
);
|
|
352
|
+
|
|
353
|
+
act(() => result.current.goTo(999999));
|
|
354
|
+
expect(result.current.currentPage).toBe(999999);
|
|
355
|
+
|
|
356
|
+
release!();
|
|
357
|
+
await waitFor(() => expect(result.current.totalPages).toBe(2));
|
|
358
|
+
await waitFor(() => expect(result.current.currentPage).toBe(2));
|
|
359
|
+
|
|
360
|
+
expect(result.current.data).toEqual(getIntArray(20, 20));
|
|
361
|
+
expect(result.current.showPreviousButton).toBe(true);
|
|
362
|
+
expect(warn).toHaveBeenCalled();
|
|
363
|
+
|
|
364
|
+
// and the user is not stuck: navigation still works
|
|
365
|
+
act(() => result.current.goToPrevious());
|
|
366
|
+
await waitFor(() => expect(result.current.currentPage).toBe(1));
|
|
367
|
+
|
|
368
|
+
warn.mockRestore();
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it('rejects a non-integer page instead of putting NaN in the request url', async () => {
|
|
372
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
373
|
+
const urls: Array<string> = [];
|
|
374
|
+
|
|
375
|
+
let release: () => void;
|
|
376
|
+
const firstResponse = new Promise<void>((resolve) => {
|
|
377
|
+
release = resolve;
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
const { result } = renderHook(() =>
|
|
381
|
+
useOpenmrsPagination('http://localhost/nan', 20, {
|
|
382
|
+
fetcher: async (url) => {
|
|
383
|
+
urls.push(url);
|
|
384
|
+
await firstResponse;
|
|
385
|
+
return { data: await getTestData(url, 100) } as any;
|
|
386
|
+
},
|
|
387
|
+
}),
|
|
388
|
+
);
|
|
389
|
+
|
|
390
|
+
act(() => result.current.goTo(Number('nope')));
|
|
391
|
+
expect(result.current.currentPage).toBe(1);
|
|
392
|
+
expect(warn).toHaveBeenCalled();
|
|
393
|
+
|
|
394
|
+
release!();
|
|
395
|
+
await waitFor(() => expect(result.current.isLoading).toBeFalsy());
|
|
396
|
+
|
|
397
|
+
expect(urls.some((u) => u.includes('startIndex=NaN'))).toBe(false);
|
|
398
|
+
expect(result.current.currentPage).toBe(1);
|
|
399
|
+
|
|
400
|
+
warn.mockRestore();
|
|
401
|
+
});
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
describe('a server that reports no total count', () => {
|
|
405
|
+
it('warns once and keeps navigation usable rather than silently dropping every request', async () => {
|
|
406
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
407
|
+
|
|
408
|
+
const { result } = renderHook(() =>
|
|
409
|
+
useOpenmrsPagination('http://localhost/nototal', 20, {
|
|
410
|
+
fetcher: async (url) => {
|
|
411
|
+
const u = new URL(url, window.location.toString());
|
|
412
|
+
const startIndex = Number.parseInt(u.searchParams.get('startIndex') ?? '0');
|
|
413
|
+
// no totalCount field, as a FHIR bundle without `total` would give
|
|
414
|
+
return { data: { results: getIntArray(startIndex, 20), links: [] } } as any;
|
|
415
|
+
},
|
|
416
|
+
}),
|
|
417
|
+
);
|
|
418
|
+
|
|
419
|
+
await waitFor(() => expect(result.current.isLoading).toBeFalsy());
|
|
420
|
+
expect(result.current.totalCount).toBeNaN();
|
|
421
|
+
|
|
422
|
+
await waitFor(() => expect(warn).toHaveBeenCalled());
|
|
423
|
+
const missingTotalWarnings = warn.mock.calls.filter((c) => String(c[0]).includes('no total count'));
|
|
424
|
+
expect(missingTotalWarnings).toHaveLength(1);
|
|
425
|
+
|
|
426
|
+
// navigation still works, since there is no bound to check against
|
|
427
|
+
act(() => result.current.goTo(3));
|
|
428
|
+
await waitFor(() => expect(result.current.currentPage).toBe(3));
|
|
429
|
+
|
|
430
|
+
warn.mockRestore();
|
|
431
|
+
});
|
|
432
|
+
});
|
|
137
433
|
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** @module @category UI */
|
|
2
2
|
import { type FetchResponse, makeUrl, openmrsFetch } from '@openmrs/esm-api';
|
|
3
|
-
import { useCallback, useMemo, useRef, useState } from 'react';
|
|
3
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
4
4
|
import useSWR, { type SWRConfiguration } from 'swr';
|
|
5
5
|
import useSWRImmutable from 'swr/immutable';
|
|
6
6
|
|
|
@@ -113,13 +113,35 @@ export function useServerPagination<T, R>(
|
|
|
113
113
|
const { getPaginatedUrl, getTotalCount, getCurrentPageSize, getData } = serverPaginationHandlers;
|
|
114
114
|
const { immutable, swrConfig } = options;
|
|
115
115
|
const fetcher: (key: string) => Promise<FetchResponse<R>> = options.fetcher ?? openmrsFetch;
|
|
116
|
-
|
|
116
|
+
// 1-indexing instead of 0-indexing, to keep consistency with `usePagination`
|
|
117
|
+
const [currentPage, setCurrentPage] = useState<number>(1);
|
|
117
118
|
|
|
118
119
|
// Cache the totalCount and currentPageSize so we don't lose them
|
|
119
120
|
// as we wait for next page's result to load while navigating to a different page.
|
|
120
121
|
// This can be used to prevent jarring UI changes while loading
|
|
121
122
|
const totalCount = useRef<number>(Number.NaN);
|
|
122
|
-
|
|
123
|
+
// this value is usually same as pageSize, except when currentPage is last page.
|
|
124
|
+
const currentPageSize = useRef<number>(Number.NaN);
|
|
125
|
+
|
|
126
|
+
// tracked as a ref so we don't need to make the current page a dependency of `goTo()`, etc.
|
|
127
|
+
const currentPageRef = useRef(currentPage);
|
|
128
|
+
|
|
129
|
+
// use to differentiate "endpoint doesn't return a total" from "we haven't run a request yet"
|
|
130
|
+
const hasResponded = useRef(false);
|
|
131
|
+
const warnedMissingTotal = useRef(false);
|
|
132
|
+
|
|
133
|
+
// if the URL we're querying changes, reset to page 1
|
|
134
|
+
const urlKey = url?.toString() ?? '';
|
|
135
|
+
const [lastUrlKey, setLastUrlKey] = useState(urlKey);
|
|
136
|
+
if (urlKey !== lastUrlKey) {
|
|
137
|
+
setLastUrlKey(urlKey);
|
|
138
|
+
setCurrentPage(1);
|
|
139
|
+
currentPageRef.current = 1;
|
|
140
|
+
totalCount.current = Number.NaN;
|
|
141
|
+
currentPageSize.current = Number.NaN;
|
|
142
|
+
hasResponded.current = false;
|
|
143
|
+
warnedMissingTotal.current = false;
|
|
144
|
+
}
|
|
123
145
|
|
|
124
146
|
const limit = pageSize;
|
|
125
147
|
const startIndex = (currentPage - 1) * pageSize;
|
|
@@ -134,34 +156,74 @@ export function useServerPagination<T, R>(
|
|
|
134
156
|
if (data?.data) {
|
|
135
157
|
totalCount.current = getTotalCount(data?.data);
|
|
136
158
|
currentPageSize.current = getCurrentPageSize(data?.data);
|
|
159
|
+
hasResponded.current = true;
|
|
137
160
|
}
|
|
138
161
|
|
|
139
162
|
const totalPages = Math.ceil(totalCount.current / pageSize);
|
|
140
163
|
|
|
164
|
+
// The bound is read from `totalCount` at call time rather than captured from the creating render,
|
|
165
|
+
// which is what keeps this callback referentially stable, apart from a change in `pageSize`.
|
|
141
166
|
const goTo = useCallback(
|
|
142
167
|
(page: number) => {
|
|
143
|
-
if (
|
|
168
|
+
if (!Number.isInteger(page) || page < 1) {
|
|
169
|
+
console.warn(`useServerPagination: ignoring goTo(${page}); page must be a positive integer.`);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// page count is not known prior to running any query when `bound` will be `NaN`, so in this
|
|
174
|
+
// case we assume the requested page is fine; after results return, things will settle properly
|
|
175
|
+
// clamped
|
|
176
|
+
const bound = Math.ceil(totalCount.current / pageSize);
|
|
177
|
+
if (!hasResponded.current || Number.isNaN(bound) || page <= bound) {
|
|
178
|
+
currentPageRef.current = page;
|
|
144
179
|
setCurrentPage(page);
|
|
145
180
|
} else {
|
|
146
181
|
console.warn('Invalid attempt to go to out of bounds page: ' + page);
|
|
147
182
|
}
|
|
148
183
|
},
|
|
149
|
-
[
|
|
184
|
+
[pageSize],
|
|
150
185
|
);
|
|
186
|
+
|
|
187
|
+
// A page accepted before the total was known may turn out not to exist, so put the user on the last
|
|
188
|
+
// real page once the response settles it. Costs an extra request, but only for a page that was out of
|
|
189
|
+
// range to begin with; without it the hook sits on an empty page with no way back.
|
|
190
|
+
useEffect(() => {
|
|
191
|
+
if (!hasResponded.current) {
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (Number.isNaN(totalCount.current)) {
|
|
196
|
+
if (!warnedMissingTotal.current) {
|
|
197
|
+
warnedMissingTotal.current = true;
|
|
198
|
+
console.warn(
|
|
199
|
+
`useServerPagination: ${urlKey} returned no total count, so out-of-range pages cannot be detected.`,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const lastPage = Math.max(1, Math.ceil(totalCount.current / pageSize));
|
|
206
|
+
if (currentPage > lastPage) {
|
|
207
|
+
console.warn(`Invalid attempt to go to out of bounds page: ${currentPage}; returning to page ${lastPage}.`);
|
|
208
|
+
currentPageRef.current = lastPage;
|
|
209
|
+
setCurrentPage(lastPage);
|
|
210
|
+
}
|
|
211
|
+
}, [currentPage, pageSize, urlKey, data]);
|
|
212
|
+
|
|
151
213
|
const goToNext = useCallback(() => {
|
|
152
|
-
goTo(
|
|
153
|
-
}, [
|
|
214
|
+
goTo(currentPageRef.current + 1);
|
|
215
|
+
}, [goTo]);
|
|
154
216
|
|
|
155
217
|
const goToPrevious = useCallback(() => {
|
|
156
|
-
goTo(
|
|
157
|
-
}, [
|
|
218
|
+
goTo(currentPageRef.current - 1);
|
|
219
|
+
}, [goTo]);
|
|
158
220
|
|
|
159
221
|
return {
|
|
160
222
|
data: getData(data?.data),
|
|
161
223
|
totalPages,
|
|
162
224
|
totalCount: totalCount.current,
|
|
163
225
|
currentPage,
|
|
164
|
-
currentPageSize,
|
|
226
|
+
currentPageSize: currentPageSize.current,
|
|
165
227
|
paginated: totalPages > 1,
|
|
166
228
|
showNextButton: currentPage < totalPages,
|
|
167
229
|
showPreviousButton: currentPage > 1,
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import React, { useEffect } from 'react';
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { act, render, renderHook } from '@testing-library/react';
|
|
4
|
+
import { usePagination } from './usePagination';
|
|
5
|
+
|
|
6
|
+
function rows(length: number) {
|
|
7
|
+
return Array.from({ length }, (_, i) => i);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe('usePagination', () => {
|
|
11
|
+
describe('callback identity', () => {
|
|
12
|
+
it('keeps the same callbacks as the data grows', () => {
|
|
13
|
+
const { result, rerender } = renderHook(({ data }) => usePagination(data, 20), {
|
|
14
|
+
initialProps: { data: rows(50) },
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const { goTo, goToNext, goToPrevious } = result.current;
|
|
18
|
+
expect(result.current.totalPages).toBe(3);
|
|
19
|
+
|
|
20
|
+
for (const length of [100, 150, 200, 250, 300, 350, 373]) {
|
|
21
|
+
rerender({ data: rows(length) });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
expect(result.current.totalPages).toBe(19);
|
|
25
|
+
expect(result.current.goTo).toBe(goTo);
|
|
26
|
+
expect(result.current.goToNext).toBe(goToNext);
|
|
27
|
+
expect(result.current.goToPrevious).toBe(goToPrevious);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('keeps the same callbacks as the page changes', () => {
|
|
31
|
+
const { result } = renderHook(() => usePagination(rows(100), 20));
|
|
32
|
+
|
|
33
|
+
const { goTo, goToNext, goToPrevious } = result.current;
|
|
34
|
+
|
|
35
|
+
act(() => result.current.goTo(3));
|
|
36
|
+
act(() => result.current.goToNext());
|
|
37
|
+
act(() => result.current.goToPrevious());
|
|
38
|
+
|
|
39
|
+
expect(result.current.currentPage).toBe(3);
|
|
40
|
+
expect(result.current.goTo).toBe(goTo);
|
|
41
|
+
expect(result.current.goToNext).toBe(goToNext);
|
|
42
|
+
expect(result.current.goToPrevious).toBe(goToPrevious);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('does not reset the page when a consumer effect depends on goTo', () => {
|
|
46
|
+
// Mirrors the advanced patient search, where results accumulate in 50-row batches while an
|
|
47
|
+
// effect keyed on `goTo` resets to page 1 — the shape that produced the reported snap-back.
|
|
48
|
+
const { result, rerender } = renderHook(
|
|
49
|
+
({ data }) => {
|
|
50
|
+
const pagination = usePagination(data, 20);
|
|
51
|
+
const { goTo } = pagination;
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
goTo(1);
|
|
54
|
+
}, [goTo]);
|
|
55
|
+
return pagination;
|
|
56
|
+
},
|
|
57
|
+
{ initialProps: { data: rows(50) } },
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
act(() => result.current.goTo(2));
|
|
61
|
+
expect(result.current.currentPage).toBe(2);
|
|
62
|
+
|
|
63
|
+
for (const length of [100, 150, 200, 250, 300, 350, 373]) {
|
|
64
|
+
rerender({ data: rows(length) });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
expect(result.current.currentPage).toBe(2);
|
|
68
|
+
expect(result.current.results).toEqual(rows(40).slice(20));
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe('clamping', () => {
|
|
73
|
+
it('clamps the page and results when the data shrinks', () => {
|
|
74
|
+
const { result, rerender } = renderHook(({ data }) => usePagination(data, 20), {
|
|
75
|
+
initialProps: { data: rows(100) },
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
act(() => result.current.goTo(5));
|
|
79
|
+
expect(result.current.currentPage).toBe(5);
|
|
80
|
+
expect(result.current.results).toEqual(rows(100).slice(80));
|
|
81
|
+
|
|
82
|
+
rerender({ data: rows(10) });
|
|
83
|
+
|
|
84
|
+
expect(result.current.totalPages).toBe(1);
|
|
85
|
+
expect(result.current.currentPage).toBe(1);
|
|
86
|
+
expect(result.current.results).toEqual(rows(10));
|
|
87
|
+
expect(result.current.showPreviousButton).toBe(false);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('clamps to page 1 when the data empties', () => {
|
|
91
|
+
const { result, rerender } = renderHook(({ data }) => usePagination(data, 20), {
|
|
92
|
+
initialProps: { data: rows(100) },
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
act(() => result.current.goTo(4));
|
|
96
|
+
rerender({ data: [] });
|
|
97
|
+
|
|
98
|
+
expect(result.current.totalPages).toBe(1);
|
|
99
|
+
expect(result.current.currentPage).toBe(1);
|
|
100
|
+
expect(result.current.results).toEqual([]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('never goes below page 1', () => {
|
|
104
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
105
|
+
const { result } = renderHook(() => usePagination(rows(100), 20));
|
|
106
|
+
|
|
107
|
+
act(() => result.current.goTo(0));
|
|
108
|
+
expect(result.current.currentPage).toBe(1);
|
|
109
|
+
|
|
110
|
+
act(() => result.current.goTo(-5));
|
|
111
|
+
expect(result.current.currentPage).toBe(1);
|
|
112
|
+
|
|
113
|
+
act(() => result.current.goToPrevious());
|
|
114
|
+
expect(result.current.currentPage).toBe(1);
|
|
115
|
+
warn.mockRestore();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('rejects a non-integer page rather than letting NaN through', () => {
|
|
119
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
120
|
+
const { result } = renderHook(() => usePagination(rows(100), 20));
|
|
121
|
+
|
|
122
|
+
act(() => result.current.goTo(Number('nope')));
|
|
123
|
+
expect(result.current.currentPage).toBe(1);
|
|
124
|
+
expect(result.current.results).toEqual(rows(20));
|
|
125
|
+
expect(warn).toHaveBeenCalled();
|
|
126
|
+
|
|
127
|
+
act(() => result.current.goTo(2.5));
|
|
128
|
+
expect(result.current.currentPage).toBe(1);
|
|
129
|
+
|
|
130
|
+
// the hook is still usable afterwards
|
|
131
|
+
act(() => result.current.goTo(3));
|
|
132
|
+
expect(result.current.currentPage).toBe(3);
|
|
133
|
+
warn.mockRestore();
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe('out-of-range requests do not linger', () => {
|
|
138
|
+
it('does not follow the tail of a growing data set after an out-of-range goTo', () => {
|
|
139
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
140
|
+
const { result, rerender } = renderHook(({ data }) => usePagination(data, 20), {
|
|
141
|
+
initialProps: { data: rows(60) },
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
act(() => result.current.goTo(999));
|
|
145
|
+
expect(result.current.currentPage).toBe(3);
|
|
146
|
+
|
|
147
|
+
rerender({ data: rows(200) });
|
|
148
|
+
expect(result.current.currentPage).toBe(3);
|
|
149
|
+
|
|
150
|
+
rerender({ data: rows(2000) });
|
|
151
|
+
expect(result.current.currentPage).toBe(3);
|
|
152
|
+
warn.mockRestore();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('does not advance later when Next is pressed at the last page and the data then grows', () => {
|
|
156
|
+
const { result, rerender } = renderHook(({ data }) => usePagination(data, 20), {
|
|
157
|
+
initialProps: { data: rows(60) },
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
act(() => result.current.goTo(3));
|
|
161
|
+
expect(result.current.showNextButton).toBe(false);
|
|
162
|
+
|
|
163
|
+
act(() => result.current.goToNext());
|
|
164
|
+
expect(result.current.currentPage).toBe(3);
|
|
165
|
+
|
|
166
|
+
rerender({ data: rows(100) });
|
|
167
|
+
expect(result.current.currentPage).toBe(3);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('does not restore an old page when the data shrinks and grows again', () => {
|
|
171
|
+
const { result, rerender } = renderHook(({ data }) => usePagination(data, 20), {
|
|
172
|
+
initialProps: { data: rows(380) },
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
act(() => result.current.goTo(19));
|
|
176
|
+
rerender({ data: rows(40) });
|
|
177
|
+
expect(result.current.currentPage).toBe(2);
|
|
178
|
+
|
|
179
|
+
rerender({ data: rows(380) });
|
|
180
|
+
expect(result.current.currentPage).toBe(2);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
describe('stepping', () => {
|
|
185
|
+
it('holds the last page when Next is pressed at the end, and Previous then moves one page', () => {
|
|
186
|
+
const { result } = renderHook(() => usePagination(rows(60), 20));
|
|
187
|
+
|
|
188
|
+
act(() => result.current.goTo(3));
|
|
189
|
+
expect(result.current.currentPage).toBe(3);
|
|
190
|
+
expect(result.current.showNextButton).toBe(false);
|
|
191
|
+
|
|
192
|
+
act(() => result.current.goToNext());
|
|
193
|
+
expect(result.current.currentPage).toBe(3);
|
|
194
|
+
|
|
195
|
+
act(() => result.current.goToNext());
|
|
196
|
+
expect(result.current.currentPage).toBe(3);
|
|
197
|
+
|
|
198
|
+
act(() => result.current.goToPrevious());
|
|
199
|
+
expect(result.current.currentPage).toBe(2);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('moves Previous one page from what is displayed after a large shrink', () => {
|
|
203
|
+
const { result, rerender } = renderHook(({ data }) => usePagination(data, 20), {
|
|
204
|
+
initialProps: { data: rows(380) },
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
act(() => result.current.goTo(19));
|
|
208
|
+
expect(result.current.currentPage).toBe(19);
|
|
209
|
+
|
|
210
|
+
rerender({ data: rows(40) });
|
|
211
|
+
expect(result.current.currentPage).toBe(2);
|
|
212
|
+
|
|
213
|
+
act(() => result.current.goToPrevious());
|
|
214
|
+
expect(result.current.currentPage).toBe(1);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// Batching the two calls into one commit is what distinguishes stepping from the latest queued page
|
|
218
|
+
// from stepping from a page captured when the callback was created; the latter is a commit behind.
|
|
219
|
+
it('steps from the latest queued page when batched with a goTo', () => {
|
|
220
|
+
const { result } = renderHook(() => usePagination(rows(200), 20));
|
|
221
|
+
const { goTo, goToNext } = result.current;
|
|
222
|
+
|
|
223
|
+
act(() => {
|
|
224
|
+
goTo(3);
|
|
225
|
+
goToNext();
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
expect(result.current.currentPage).toBe(4);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('steps back from the latest queued page when batched with a goTo', () => {
|
|
232
|
+
const { result } = renderHook(() => usePagination(rows(200), 20));
|
|
233
|
+
const { goTo, goToPrevious } = result.current;
|
|
234
|
+
|
|
235
|
+
act(() => {
|
|
236
|
+
goTo(5);
|
|
237
|
+
goToPrevious();
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
expect(result.current.currentPage).toBe(4);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('steps correctly when called from a child effect during a shrink', () => {
|
|
244
|
+
const seen: Array<number> = [];
|
|
245
|
+
const box: { goTo?: (n: number) => void } = {};
|
|
246
|
+
let armed = false;
|
|
247
|
+
|
|
248
|
+
function Child({ onPrev }: { onPrev: () => void }) {
|
|
249
|
+
useEffect(() => {
|
|
250
|
+
if (armed) {
|
|
251
|
+
armed = false;
|
|
252
|
+
onPrev();
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function Parent({ data }: { data: Array<number> }) {
|
|
259
|
+
const { currentPage, goTo, goToPrevious } = usePagination(data, 20);
|
|
260
|
+
seen.push(currentPage);
|
|
261
|
+
box.goTo = goTo;
|
|
262
|
+
return <Child onPrev={goToPrevious} />;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const { rerender } = render(<Parent data={rows(380)} />);
|
|
266
|
+
act(() => box.goTo!(19));
|
|
267
|
+
expect(seen[seen.length - 1]).toBe(19);
|
|
268
|
+
|
|
269
|
+
// the data shrinks to 2 pages and Previous is pressed in that same commit
|
|
270
|
+
armed = true;
|
|
271
|
+
rerender(<Parent data={rows(40)} />);
|
|
272
|
+
|
|
273
|
+
expect(seen[seen.length - 1]).toBe(1);
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
describe('returned shape', () => {
|
|
278
|
+
it('paginates and reports flags as before', () => {
|
|
279
|
+
const { result } = renderHook(() => usePagination(rows(45), 20));
|
|
280
|
+
|
|
281
|
+
expect(result.current.totalPages).toBe(3);
|
|
282
|
+
expect(result.current.currentPage).toBe(1);
|
|
283
|
+
expect(result.current.paginated).toBe(true);
|
|
284
|
+
expect(result.current.showNextButton).toBe(true);
|
|
285
|
+
expect(result.current.showPreviousButton).toBe(false);
|
|
286
|
+
expect(result.current.results).toEqual(rows(20));
|
|
287
|
+
|
|
288
|
+
act(() => result.current.goToNext());
|
|
289
|
+
expect(result.current.results).toEqual(rows(40).slice(20));
|
|
290
|
+
expect(result.current.showPreviousButton).toBe(true);
|
|
291
|
+
|
|
292
|
+
act(() => result.current.goToNext());
|
|
293
|
+
expect(result.current.results).toEqual(rows(45).slice(40));
|
|
294
|
+
expect(result.current.showNextButton).toBe(false);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it('is not paginated when the data fits on one page', () => {
|
|
298
|
+
const { result } = renderHook(() => usePagination(rows(5), 20));
|
|
299
|
+
|
|
300
|
+
expect(result.current.totalPages).toBe(1);
|
|
301
|
+
expect(result.current.paginated).toBe(false);
|
|
302
|
+
expect(result.current.results).toEqual(rows(5));
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
// Characterises a long-standing quirk rather than endorsing it: an unusable `resultsPerPage` reports
|
|
306
|
+
// a single page but slices no rows out of it, so the caller gets an empty list with no diagnostic.
|
|
307
|
+
it('reports one page and no results when resultsPerPage is not a usable number', () => {
|
|
308
|
+
const { result } = renderHook(() => usePagination(rows(50), 0));
|
|
309
|
+
|
|
310
|
+
expect(result.current.totalPages).toBe(1);
|
|
311
|
+
expect(result.current.currentPage).toBe(1);
|
|
312
|
+
expect(result.current.results).toEqual([]);
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
});
|
package/src/usePagination.ts
CHANGED
|
@@ -6,6 +6,11 @@ const defaultResultsPerPage = 10;
|
|
|
6
6
|
/**
|
|
7
7
|
* Use this hook to paginate data that already exists on the client side.
|
|
8
8
|
* Note that if the data is obtained from server-side, the caller must handle server-side pagination manually.
|
|
9
|
+
*
|
|
10
|
+
* `goTo`, `goToNext` and `goToPrevious` keep the same identity for the life of the component, so they are
|
|
11
|
+
* safe to list in a dependency array. `currentPage` is bounded by the data, so it can change on its own
|
|
12
|
+
* when the data shrinks; a component reacting to `currentPage` should expect that.
|
|
13
|
+
*
|
|
9
14
|
* @see `useServerPagination` for hook that automatically manages server-side pagination.
|
|
10
15
|
* @see `useServerInfinite` for hook to get all data loaded onto the client-side
|
|
11
16
|
* @param data
|
|
@@ -13,7 +18,7 @@ const defaultResultsPerPage = 10;
|
|
|
13
18
|
* @returns
|
|
14
19
|
*/
|
|
15
20
|
export function usePagination<T>(data: Array<T> = [], resultsPerPage = defaultResultsPerPage) {
|
|
16
|
-
const [
|
|
21
|
+
const [requestedPage, setRequestedPage] = useState(1);
|
|
17
22
|
const totalPages = useMemo(
|
|
18
23
|
() =>
|
|
19
24
|
typeof resultsPerPage === 'number' && resultsPerPage > 0
|
|
@@ -22,29 +27,36 @@ export function usePagination<T>(data: Array<T> = [], resultsPerPage = defaultRe
|
|
|
22
27
|
[data.length, resultsPerPage],
|
|
23
28
|
);
|
|
24
29
|
|
|
30
|
+
// If requested page is past the end, set to the end. Done here to retain referential stability
|
|
31
|
+
// on the functions returned by this hook
|
|
32
|
+
if (requestedPage > totalPages) {
|
|
33
|
+
setRequestedPage(totalPages);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const page = Math.min(Math.max(1, requestedPage), totalPages);
|
|
37
|
+
|
|
25
38
|
const results = useMemo(() => {
|
|
26
39
|
const lowerBound = (page - 1) * resultsPerPage;
|
|
27
40
|
const upperBound = (page + 0) * resultsPerPage;
|
|
28
41
|
return data.slice(lowerBound, upperBound);
|
|
29
42
|
}, [data, page, resultsPerPage]);
|
|
30
43
|
|
|
31
|
-
const goTo = useCallback(
|
|
32
|
-
(page
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
[setPage, totalPages],
|
|
36
|
-
);
|
|
37
|
-
const goToNext = useCallback(() => {
|
|
38
|
-
if (page < totalPages) {
|
|
39
|
-
setPage(page + 1);
|
|
44
|
+
const goTo = useCallback((page: number) => {
|
|
45
|
+
if (!Number.isInteger(page) || page < 1) {
|
|
46
|
+
console.warn(`usePagination: ignoring goTo(${page}); page must be a positive integer.`);
|
|
47
|
+
return;
|
|
40
48
|
}
|
|
41
|
-
|
|
49
|
+
setRequestedPage(page);
|
|
50
|
+
}, []);
|
|
51
|
+
|
|
52
|
+
// overflow clamping happens in render; see the setRequestedPage() call above
|
|
53
|
+
const goToNext = useCallback(() => {
|
|
54
|
+
setRequestedPage((p) => p + 1);
|
|
55
|
+
}, []);
|
|
42
56
|
|
|
43
57
|
const goToPrevious = useCallback(() => {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
}
|
|
47
|
-
}, [page, setPage]);
|
|
58
|
+
setRequestedPage((p) => Math.max(1, p - 1));
|
|
59
|
+
}, []);
|
|
48
60
|
|
|
49
61
|
const memoisedPaginatedData = useMemo(
|
|
50
62
|
() => ({
|