Skip to content

Commit fa3ebae

Browse files
committed
fix: pagination hook
1 parent caa81df commit fa3ebae

File tree

4 files changed

+41
-26
lines changed

4 files changed

+41
-26
lines changed

src/api/api.ts

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,10 @@ type ResponseHandlerExtendsType<T> = Partial<Record<'success' | StatusCodesSucce
6060
type VoidToUndefinedReturn<T extends (...args: any) => any> =
6161
ReturnType<T> extends void ? (...args: Parameters<T>) => undefined : T;
6262

63+
export interface Abortable {
64+
abort(): void;
65+
}
66+
6367
/**
6468
* The response handler is a class that allows you to handle the response of a request to the API.
6569
* It allows you to define what to do when the request is successful, when it returns an error (an API error), when it fails (a request error), or when it returns a specific status code or specific failure.
@@ -84,11 +88,16 @@ type VoidToUndefinedReturn<T extends (...args: any) => any> =
8488
* .toPromise();
8589
* }
8690
*/
87-
export class ResponseHandler<T, R extends ResponseHandlerExtendsType<T> = { fallback: HandlerNoParam<undefined> }> {
91+
export class ResponseHandler<T, R extends ResponseHandlerExtendsType<T> = { fallback: HandlerNoParam<undefined> }>
92+
implements Abortable
93+
{
8894
private readonly handlers = { fallback: () => undefined } as R;
8995
private readonly promise: Promise<ReturnType<R[keyof R] extends (...args: any) => any ? R[keyof R] : never>>;
9096

91-
constructor(rawResponse: Promise<APIResponse<T>>) {
97+
constructor(
98+
rawResponse: Promise<APIResponse<T>>,
99+
private readonly abortController: AbortController,
100+
) {
92101
this.promise = rawResponse.then((response) => {
93102
if ('failureReason' in response) {
94103
if (this.handlers[response.failureReason]) {
@@ -152,6 +161,14 @@ export class ResponseHandler<T, R extends ResponseHandlerExtendsType<T> = { fall
152161
>;
153162
}
154163

164+
/**
165+
* Aborts the request that produces this reponse.
166+
* Fires ResponseError.timeout handler or falls back to failure handler.
167+
*/
168+
abort() {
169+
this.abortController.abort();
170+
}
171+
155172
async toPromise(): Promise<Awaited<ReturnType<R[keyof R] extends (...args: any) => any ? R[keyof R] : never>>> {
156173
return await this.promise;
157174
}
@@ -193,6 +210,7 @@ async function internalRequestAPI<RequestType>(
193210
version: string,
194211
isFile: true,
195212
applicationId: string,
213+
abortController: AbortController,
196214
): Promise<APIResponse<Blob>>;
197215
async function internalRequestAPI<RequestType, ResponseType>(
198216
method: string,
@@ -202,6 +220,7 @@ async function internalRequestAPI<RequestType, ResponseType>(
202220
version: string,
203221
isFile: boolean,
204222
applicationId: string,
223+
abortController: AbortController,
205224
): Promise<APIResponse<ResponseType>>;
206225
async function internalRequestAPI<RequestType, ResponseType>(
207226
method: string,
@@ -211,6 +230,7 @@ async function internalRequestAPI<RequestType, ResponseType>(
211230
version: string,
212231
isFile: boolean,
213232
applicationId: string,
233+
abortController: AbortController,
214234
): Promise<APIResponse<ResponseType | Blob>> {
215235
// Generate headers
216236
const headers = new Headers();
@@ -219,7 +239,6 @@ async function internalRequestAPI<RequestType, ResponseType>(
219239
if (!isFile) headers.append('Content-Type', 'application/json');
220240

221241
// Add timeout to the request
222-
const abortController = new AbortController();
223242
const timeout = setTimeout(() => {
224243
abortController.abort();
225244
}, timeoutMillis);
@@ -309,7 +328,11 @@ function requestAPI<RequestType, ResponseType>(
309328
applicationId = etuuttWebApplicationId,
310329
}: { timeoutMillis?: number; version?: string; isFile?: boolean; applicationId?: string } = {},
311330
): ResponseHandler<ResponseType> {
312-
return new ResponseHandler(internalRequestAPI(method, route, body, timeoutMillis, version, isFile, applicationId));
331+
const abortController = new AbortController();
332+
return new ResponseHandler(
333+
internalRequestAPI(method, route, body, timeoutMillis, version, isFile, applicationId, abortController),
334+
abortController,
335+
);
313336
}
314337

315338
// Set the authorization header with the given token for next requests

src/api/pagination.hook.ts

Lines changed: 12 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useState, useRef } from 'react';
2-
import { useAPI } from './api';
2+
import { Abortable, ResponseFailureReason, useAPI } from './api';
33
import { Pagination } from './api.interface';
44

55
type PaginationHook<T> = {
@@ -15,57 +15,49 @@ type PaginationHook<T> = {
1515
export function usePaginationLoader<T>(path: string): PaginationHook<T> {
1616
const [items, setItems] = useState<T[]>([]);
1717
const [total, setTotal] = useState(0);
18-
const onUpdate = useRef<Promise<void>>();
18+
const handler = useRef<Abortable | undefined>(undefined);
1919
const lastSearch = useRef<Record<string, string>>({});
2020
const itemsPerPage = useRef(0);
2121
const pageIndex = useRef(1);
2222

2323
const api = useAPI();
2424

2525
const updateItems = (query: Record<string, string>) => {
26-
let resolvePromise: () => void;
27-
if (onUpdate.current) {
28-
onUpdate.current.then(() => updateItems(query));
29-
return;
30-
} else onUpdate.current = new Promise((res) => (resolvePromise = res));
26+
if (handler.current) handler.current.abort();
3127

3228
setItems([...Array(itemsPerPage.current || 20).fill(null)]);
3329
const { page, ...queryData } = query;
34-
api
30+
handler.current = api
3531
.get<Pagination<T>>(`${path}?${new URLSearchParams(query)}`)
3632
.on('success', (body) => {
3733
setTotal(body.itemCount);
3834
setItems(body.items);
3935
itemsPerPage.current = body.itemsPerPage;
4036
lastSearch.current = queryData;
4137
pageIndex.current = (page && Number(page)) || 1;
42-
delete onUpdate.current;
43-
resolvePromise();
38+
delete handler.current;
4439
})
45-
.on('error', () => (delete onUpdate.current, resolvePromise()))
46-
.on('failure', () => (delete onUpdate.current, resolvePromise()));
40+
.on('fallback', () => delete handler.current)
41+
.on(ResponseFailureReason.timeout, () => {}); // Hide toast error here as we might have aborted the request
4742
};
4843

4944
const fetchNextPage = () => {
50-
let resolvePromise: () => void;
51-
if (onUpdate.current) return;
52-
else onUpdate.current = new Promise((res) => (resolvePromise = res));
45+
if (handler.current) return;
5346

5447
const pendingItemCount = Math.min(itemsPerPage.current, total - items.length);
5548
setItems((prev) => [...prev, ...Array(pendingItemCount).fill(null)]);
56-
api
49+
handler.current = api
5750
.get<Pagination<T>>(
5851
`${path}?${new URLSearchParams({ ...lastSearch.current, page: String(pageIndex.current + 1) })}`,
5952
)
6053
.on('success', (body) => {
6154
pageIndex.current++;
6255
setTotal(body.itemCount);
6356
setItems((prev) => [...prev.slice(0, prev.length - pendingItemCount), ...body.items]);
64-
delete onUpdate.current;
65-
resolvePromise();
57+
delete handler.current;
6658
})
67-
.on('error', () => (delete onUpdate.current, resolvePromise()))
68-
.on('failure', () => (delete onUpdate.current, resolvePromise()));
59+
.on('fallback', () => delete handler.current)
60+
.on(ResponseFailureReason.timeout, () => {}); // Hide toast error here as we might have aborted the request
6961
};
7062
return { items, total, updateFilters: updateItems, fetchNextItems: fetchNextPage };
7163
}

src/components/ResultsList.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ export function ResultsList<T extends object>({
3636
const visibilityTrigger = useRef<HTMLDivElement>(null);
3737

3838
useEffect(() => {
39-
if (onEndReached && data[data.length - 1]) {
39+
if (onEndReached && data[data.length - 1] && totalResults > data.length) {
4040
const intersectionObserver = new IntersectionObserver(
4141
([entry]) => {
4242
if (entry.isIntersecting) {

src/components/UI/ModalForm.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ interface DataModalType {
2626

2727
/**
2828
* All keys of {@link DataModalType} whose type require an `options` field in {@link DataModalEntry}.
29-
* These are types are already arrays.
29+
* These types are already arrays.
3030
*/
3131
type OptionsFieldsRequired = 'stringList';
3232
/**

0 commit comments

Comments
 (0)