260 lines
6.6 KiB
TypeScript
260 lines
6.6 KiB
TypeScript
"use client";
|
|
|
|
import { AxiosError } from "axios";
|
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
|
|
const isInvalidCursorError = (error: unknown) => {
|
|
const axiosError = error as AxiosError<{ error?: { message?: string } }>;
|
|
if (axiosError.response?.status !== 400) return false;
|
|
const message = axiosError.response?.data?.error?.message ?? "";
|
|
return /cursor|pagination/i.test(message);
|
|
};
|
|
|
|
type UseHybridPaginationOptions = {
|
|
meta?: {
|
|
limit: number;
|
|
offset: number;
|
|
page?: number;
|
|
pages?: number;
|
|
has_more?: boolean;
|
|
next_cursor?: string;
|
|
};
|
|
params: Record<string, unknown>;
|
|
setParams: React.Dispatch<React.SetStateAction<Record<string, any>>>;
|
|
isError?: boolean;
|
|
error?: unknown;
|
|
};
|
|
|
|
/** Each push records the absolute page we were on when this cursor was added,
|
|
* so a later pop can fall back to that page via offset if needed. */
|
|
type CursorStackEntry = { cursor: string; fromPage: number };
|
|
|
|
export const useHybridPagination = ({
|
|
meta,
|
|
params,
|
|
setParams,
|
|
isError,
|
|
error,
|
|
}: UseHybridPaginationOptions) => {
|
|
const [cursorStack, setCursorStack] = useState<CursorStackEntry[]>([]);
|
|
const [lastValidPages, setLastValidPages] = useState<number>(1);
|
|
const [lastValidLimit, setLastValidLimit] = useState<number>(10);
|
|
|
|
const limit = meta?.limit ?? Number(params.limit) ?? lastValidLimit;
|
|
|
|
useEffect(() => {
|
|
if (meta?.limit != null) {
|
|
setLastValidLimit(meta.limit);
|
|
}
|
|
}, [meta?.limit]);
|
|
|
|
useEffect(() => {
|
|
if (meta?.pages != null) {
|
|
setLastValidPages(meta.pages);
|
|
}
|
|
}, [meta?.pages]);
|
|
|
|
const clearCursorStack = useCallback(() => {
|
|
setCursorStack([]);
|
|
}, []);
|
|
|
|
const resetToFirstPage = useCallback(() => {
|
|
clearCursorStack();
|
|
setParams((current) => {
|
|
const next: Record<string, any> = { ...current, offset: 0, limit };
|
|
delete next.cursor;
|
|
return next;
|
|
});
|
|
}, [clearCursorStack, limit, setParams]);
|
|
|
|
useEffect(() => {
|
|
if (!isError || !error || !isInvalidCursorError(error)) return;
|
|
resetToFirstPage();
|
|
}, [isError, error, resetToFirstPage]);
|
|
|
|
const hasNext = Boolean(meta?.has_more && meta?.next_cursor);
|
|
|
|
/** 0-based page index; do not trust meta.page alone while cursor pages are active. */
|
|
const currentPageIndex = useMemo(() => {
|
|
const pageSize = limit > 0 ? limit : 1;
|
|
|
|
if (cursorStack.length > 0) {
|
|
const last = cursorStack[cursorStack.length - 1];
|
|
return Math.max(0, last.fromPage - 1 + cursorStack.length);
|
|
}
|
|
|
|
const paramsCursor =
|
|
typeof params.cursor === "string" ? params.cursor.trim() : "";
|
|
const paramsOffset = Number(params.offset);
|
|
if (!paramsCursor && Number.isFinite(paramsOffset) && paramsOffset >= 0) {
|
|
return Math.floor(paramsOffset / pageSize);
|
|
}
|
|
|
|
const metaOffset = Number(meta?.offset);
|
|
if (Number.isFinite(metaOffset) && metaOffset >= 0) {
|
|
return Math.floor(metaOffset / pageSize);
|
|
}
|
|
|
|
if (meta?.page != null) return meta.page - 1;
|
|
return 0;
|
|
}, [
|
|
cursorStack,
|
|
params.cursor,
|
|
params.offset,
|
|
meta?.offset,
|
|
meta?.page,
|
|
limit,
|
|
]);
|
|
|
|
const hasPrevious = cursorStack.length > 0 || currentPageIndex > 0;
|
|
|
|
const pagination = useMemo(
|
|
() => ({
|
|
currentPage: currentPageIndex,
|
|
totalPages: meta?.pages ?? lastValidPages,
|
|
}),
|
|
[currentPageIndex, meta?.pages, lastValidPages],
|
|
);
|
|
|
|
const goToOffsetPage = useCallback(
|
|
(pageIndex: number) => {
|
|
clearCursorStack();
|
|
setParams((current) => {
|
|
const next: Record<string, any> = {
|
|
...current,
|
|
offset: pageIndex * limit,
|
|
limit,
|
|
};
|
|
delete next.cursor;
|
|
return next;
|
|
});
|
|
},
|
|
[clearCursorStack, limit, setParams],
|
|
);
|
|
|
|
const goToNextCursorPage = useCallback(
|
|
(fromPage1Based: number) => {
|
|
const nextCursor = meta?.next_cursor;
|
|
if (!meta?.has_more || !nextCursor) return;
|
|
|
|
setCursorStack((stack) => [
|
|
...stack,
|
|
{ cursor: nextCursor, fromPage: fromPage1Based },
|
|
]);
|
|
setParams((current) => {
|
|
const next: Record<string, any> = {
|
|
...current,
|
|
cursor: nextCursor,
|
|
limit,
|
|
};
|
|
delete next.offset;
|
|
return next;
|
|
});
|
|
},
|
|
[limit, meta?.has_more, meta?.next_cursor, setParams],
|
|
);
|
|
|
|
const goToPreviousPage = useCallback(() => {
|
|
if (cursorStack.length > 0) {
|
|
const poppedEntry = cursorStack[cursorStack.length - 1];
|
|
const nextStack = cursorStack.slice(0, -1);
|
|
const previousEntry = nextStack[nextStack.length - 1];
|
|
|
|
setCursorStack(nextStack);
|
|
setParams((current) => {
|
|
const next: Record<string, any> = { ...current, limit };
|
|
if (previousEntry) {
|
|
next.cursor = previousEntry.cursor;
|
|
delete next.offset;
|
|
} else if (poppedEntry.fromPage > 1) {
|
|
next.offset = (poppedEntry.fromPage - 1) * limit;
|
|
delete next.cursor;
|
|
} else {
|
|
next.offset = 0;
|
|
delete next.cursor;
|
|
}
|
|
return next;
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (currentPageIndex <= 0) {
|
|
resetToFirstPage();
|
|
return;
|
|
}
|
|
|
|
goToOffsetPage(currentPageIndex - 1);
|
|
}, [
|
|
cursorStack,
|
|
currentPageIndex,
|
|
goToOffsetPage,
|
|
limit,
|
|
resetToFirstPage,
|
|
setParams,
|
|
]);
|
|
|
|
const handlePaginationChange = useCallback(
|
|
(e: { selected: number }) => {
|
|
const targetIndex = e.selected;
|
|
const delta = targetIndex - currentPageIndex;
|
|
|
|
if (delta === 1 && hasNext) {
|
|
goToNextCursorPage(currentPageIndex + 1);
|
|
return;
|
|
}
|
|
|
|
if (delta === -1) {
|
|
const cursorReturnIndex =
|
|
cursorStack.length > 0
|
|
? cursorStack[cursorStack.length - 1].fromPage - 1
|
|
: null;
|
|
|
|
if (
|
|
cursorReturnIndex != null &&
|
|
targetIndex === cursorReturnIndex &&
|
|
cursorStack.length > 0
|
|
) {
|
|
goToPreviousPage();
|
|
return;
|
|
}
|
|
|
|
if (targetIndex >= 0) {
|
|
goToOffsetPage(targetIndex);
|
|
return;
|
|
}
|
|
}
|
|
|
|
goToOffsetPage(targetIndex);
|
|
},
|
|
[
|
|
currentPageIndex,
|
|
cursorStack,
|
|
goToNextCursorPage,
|
|
goToOffsetPage,
|
|
goToPreviousPage,
|
|
hasNext,
|
|
],
|
|
);
|
|
|
|
const buildFirstPageParams = useCallback(
|
|
(base: Record<string, any>) => {
|
|
const next: Record<string, any> = { ...base, offset: 0, limit };
|
|
delete next.cursor;
|
|
clearCursorStack();
|
|
return next;
|
|
},
|
|
[clearCursorStack, limit],
|
|
);
|
|
|
|
return {
|
|
pagination,
|
|
handlePaginationChange,
|
|
buildFirstPageParams,
|
|
resetToFirstPage,
|
|
clearCursorStack,
|
|
currentPageIndex,
|
|
hasNext,
|
|
hasPrevious,
|
|
};
|
|
};
|