Skip to content

Commit eaa757a

Browse files
committed
fix: pagination hook
1 parent 2acfb21 commit eaa757a

File tree

4 files changed

+39
-27
lines changed

4 files changed

+39
-27
lines changed

src/api/api.ts

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ type RawResponseType<T> = T extends Date
3434
}
3535
: T;
3636

37+
export interface Abortable {
38+
abort(): void;
39+
}
40+
3741
/**
3842
* The response handler is a class that allows you to handle the response of a request to the API.
3943
* 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.
@@ -58,18 +62,22 @@ type RawResponseType<T> = T extends Date
5862
* .toPromise();
5963
* }
6064
*/
61-
export class ResponseHandler<
65+
class ResponseHandler<
6266
T,
6367
R extends { [status in StatusCodes]?: any } & { fallback: any } & {
6468
[status in ResponseError | 'success' | 'error' | 'failure']?: any;
6569
} = { fallback: undefined },
66-
> {
70+
> implements Abortable
71+
{
6772
private readonly handlers = { fallback: () => undefined } as {
6873
[K in keyof R]: K extends 'success' | StatusCodes ? (body: T) => R[K] : () => R[K];
6974
};
7075
private readonly promise: Promise<R[keyof R]>;
7176

72-
constructor(rawResponse: Promise<APIResponse<T>>) {
77+
constructor(
78+
rawResponse: Promise<APIResponse<T>>,
79+
private readonly abortController: AbortController,
80+
) {
7381
this.promise = rawResponse.then((response) => {
7482
if ('error' in response) {
7583
return this.handlers[response.error]
@@ -96,6 +104,14 @@ export class ResponseHandler<
96104
return this as unknown as ResponseHandler<T, { [K in S | keyof R]: K extends S ? O : R[K] }>;
97105
}
98106

107+
/**
108+
* Aborts the request that produces this reponse.
109+
* Fires ResponseError.timeout handler or falls back to failure handler.
110+
*/
111+
abort() {
112+
this.abortController.abort();
113+
}
114+
99115
async toPromise(): Promise<
100116
Awaited<Exclude<R[keyof R], void | undefined> | (undefined extends R[keyof R] ? null : never)> // For some reason, undefined extends void. See https://github.com/ungdev/etu-utt-front/pull/28/files#r2357325209, Alban got the explanation
101117
> {
@@ -140,6 +156,7 @@ async function internalRequestAPI<RequestType>(
140156
version: string,
141157
isFile: true,
142158
applicationId: string,
159+
abortController: AbortController,
143160
): Promise<APIResponse<Blob>>;
144161
async function internalRequestAPI<RequestType, ResponseType>(
145162
method: string,
@@ -149,6 +166,7 @@ async function internalRequestAPI<RequestType, ResponseType>(
149166
version: string,
150167
isFile: boolean,
151168
applicationId: string,
169+
abortController: AbortController,
152170
): Promise<APIResponse<ResponseType>>;
153171
async function internalRequestAPI<RequestType, ResponseType>(
154172
method: string,
@@ -158,6 +176,7 @@ async function internalRequestAPI<RequestType, ResponseType>(
158176
version: string,
159177
isFile: boolean,
160178
applicationId: string,
179+
abortController: AbortController,
161180
): Promise<APIResponse<ResponseType | Blob>> {
162181
// Generate headers
163182
const headers = new Headers();
@@ -166,7 +185,6 @@ async function internalRequestAPI<RequestType, ResponseType>(
166185
if (!isFile) headers.append('Content-Type', 'application/json');
167186

168187
// Add timeout to the request
169-
const abortController = new AbortController();
170188
const timeout = setTimeout(() => {
171189
abortController.abort();
172190
}, timeoutMillis);
@@ -255,7 +273,11 @@ function requestAPI<RequestType, ResponseType>(
255273
applicationId = etuuttWebApplicationId,
256274
}: { timeoutMillis?: number; version?: string; isFile?: boolean; applicationId?: string } = {},
257275
): ResponseHandler<ResponseType> {
258-
return new ResponseHandler(internalRequestAPI(method, route, body, timeoutMillis, version, isFile, applicationId));
276+
const abortController = new AbortController();
277+
return new ResponseHandler(
278+
internalRequestAPI(method, route, body, timeoutMillis, version, isFile, applicationId, abortController),
279+
abortController,
280+
);
259281
}
260282

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

src/api/pagination.hook.ts

Lines changed: 10 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, useAPI } from './api';
33
import { Pagination } from './api.interface';
44

55
type PaginationHook<T> = {
@@ -15,57 +15,47 @@ 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('error', () => delete handler.current);
4741
};
4842

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

5446
const pendingItemCount = Math.min(itemsPerPage.current, total - items.length);
5547
setItems((prev) => [...prev, ...Array(pendingItemCount).fill(null)]);
56-
api
48+
handler.current = api
5749
.get<Pagination<T>>(
5850
`${path}?${new URLSearchParams({ ...lastSearch.current, page: String(pageIndex.current + 1) })}`,
5951
)
6052
.on('success', (body) => {
6153
pageIndex.current++;
6254
setTotal(body.itemCount);
6355
setItems((prev) => [...prev.slice(0, prev.length - pendingItemCount), ...body.items]);
64-
delete onUpdate.current;
65-
resolvePromise();
56+
delete handler.current;
6657
})
67-
.on('error', () => (delete onUpdate.current, resolvePromise()))
68-
.on('failure', () => (delete onUpdate.current, resolvePromise()));
58+
.on('error', () => delete handler.current);
6959
};
7060
return { items, total, updateFilters: updateItems, fetchNextItems: fetchNextPage };
7161
}

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)