diff --git a/app/features/tournament-admin/loaders/to.$id.admin.audit.server.ts b/app/features/tournament-admin/loaders/to.$id.admin.audit.server.ts index cf172425d..75d86976f 100644 --- a/app/features/tournament-admin/loaders/to.$id.admin.audit.server.ts +++ b/app/features/tournament-admin/loaders/to.$id.admin.audit.server.ts @@ -7,9 +7,9 @@ import { AUDIT_LOG_PAGE_SIZE } from "~/features/tournament/TournamentAuditLogRep import { tournamentFromDBCached } from "~/features/tournament-bracket/core/Tournament.server"; import { forbidden, + paginate, parseParams, parseSearchParams, - redirectIfPageOutOfBounds, } from "~/utils/remix.server"; import { idObject } from "~/utils/zod"; @@ -48,16 +48,11 @@ export const loader = async ({ request, params, url }: LoaderFunctionArgs) => { TournamentAuditLogRepository.findTeamsByTournamentId(tournamentId), ]); - const pagesCount = Math.max(1, Math.ceil(totalCount / AUDIT_LOG_PAGE_SIZE)); - - redirectIfPageOutOfBounds({ url, page, pagesCount }); - return { auditLog: { events, teams, - currentPage: page, - pagesCount, + ...paginate({ url, page, pageSize: AUDIT_LOG_PAGE_SIZE, totalCount }), }, }; }; diff --git a/app/features/tournament-admin/routes/to.$id.admin.audit.tsx b/app/features/tournament-admin/routes/to.$id.admin.audit.tsx index a64ccbf71..d28881a61 100644 --- a/app/features/tournament-admin/routes/to.$id.admin.audit.tsx +++ b/app/features/tournament-admin/routes/to.$id.admin.audit.tsx @@ -7,6 +7,7 @@ import { Pagination } from "~/components/Pagination"; import { Table } from "~/components/Table"; import { TOURNAMENT_AUDIT_LOG_TYPES } from "~/db/tables"; import { useTournament } from "~/features/tournament/routes/to.$id"; +import { useSearchParamPagination } from "~/hooks/useSearchParamPagination"; import type { CommonUser } from "~/utils/kysely.server"; import { tournamentTeamPage, userPage } from "~/utils/urls"; import type { TournamentAdminAuditLoader } from "../loaders/to.$id.admin.audit.server"; @@ -25,17 +26,14 @@ const WHEN_FORMAT_OPTIONS = { export default function TournamentAdminAuditLog() { const { t } = useTranslation(["tournament"]); const data = useLoaderData(); - const [, setSearchParams] = useSearchParams(); - const auditLog = data?.auditLog; - if (!auditLog) return null; - const setPage = (page: number) => { - setSearchParams((params) => { - params.set("page", String(page)); - return params; - }); - }; + const pagination = useSearchParamPagination({ + currentPage: auditLog?.currentPage ?? 1, + pagesCount: auditLog?.pagesCount ?? 1, + }); + + if (!auditLog) return null; return (
@@ -62,15 +60,7 @@ export default function TournamentAdminAuditLog() { ))} - {auditLog.pagesCount > 1 ? ( - setPage(auditLog.currentPage + 1)} - previousPage={() => setPage(auditLog.currentPage - 1)} - setPage={setPage} - /> - ) : null} + {auditLog.pagesCount > 1 ? : null} )}
diff --git a/app/features/user-page/loaders/u.$identifier.results.server.ts b/app/features/user-page/loaders/u.$identifier.results.server.ts index fb27426de..a3d4ff170 100644 --- a/app/features/user-page/loaders/u.$identifier.results.server.ts +++ b/app/features/user-page/loaders/u.$identifier.results.server.ts @@ -4,8 +4,8 @@ import * as UserRepository from "~/features/user-page/UserRepository.server"; import type { SerializeFrom } from "~/utils/remix"; import { notFoundIfNullish, + paginate, parseSafeSearchParams, - redirectIfPageOutOfBounds, } from "~/utils/remix.server"; import { HIGHLIGHTS_RESULTS_MAX, @@ -60,15 +60,10 @@ export const loader = async ({ params, request, url }: LoaderFunctionArgs) => { }), ]); - const pagesCount = Math.ceil(totalCount / RESULTS_PER_PAGE); - - redirectIfPageOutOfBounds({ url, page, pagesCount }); - return { results: { value: results, - currentPage: page, - pages: pagesCount, + ...paginate({ url, page, pageSize: RESULTS_PER_PAGE, totalCount }), }, hasHighlightedResults, }; diff --git a/app/features/user-page/loaders/u.$identifier.seasons.index.server.ts b/app/features/user-page/loaders/u.$identifier.seasons.index.server.ts index dd59b3c98..d1fc3fd97 100644 --- a/app/features/user-page/loaders/u.$identifier.seasons.index.server.ts +++ b/app/features/user-page/loaders/u.$identifier.seasons.index.server.ts @@ -42,7 +42,7 @@ export const loader = async ({ params, url }: LoaderFunctionArgs) => { page, }), currentPage: page, - pages: await SQMatchRepository.seasonResultPagesByUserId({ + pagesCount: await SQMatchRepository.seasonResultPagesByUserId({ season, userId: user.id, }), diff --git a/app/features/user-page/loaders/u.$identifier.vods.server.ts b/app/features/user-page/loaders/u.$identifier.vods.server.ts index 235e8c9dc..bd5f50de5 100644 --- a/app/features/user-page/loaders/u.$identifier.vods.server.ts +++ b/app/features/user-page/loaders/u.$identifier.vods.server.ts @@ -5,8 +5,8 @@ import { VODS_PAGE_BATCH_SIZE } from "~/features/vods/vods-constants"; import { userVodsSearchParamsSchema } from "~/features/vods/vods-schemas"; import { notFoundIfNullish, + paginate, parseSearchParams, - redirectIfPageOutOfBounds, } from "~/utils/remix.server"; export const loader = async ({ params, request, url }: LoaderFunctionArgs) => { @@ -28,13 +28,8 @@ export const loader = async ({ params, request, url }: LoaderFunctionArgs) => { VodRepository.countVods({ userId }), ]); - const pagesCount = Math.max(1, Math.ceil(totalCount / VODS_PAGE_BATCH_SIZE)); - - redirectIfPageOutOfBounds({ url, page, pagesCount }); - return { vods, - currentPage: page, - pagesCount, + ...paginate({ url, page, pageSize: VODS_PAGE_BATCH_SIZE, totalCount }), }; }; diff --git a/app/features/user-page/routes/u.$identifier.results.tsx b/app/features/user-page/routes/u.$identifier.results.tsx index 4ae465325..0c1021457 100644 --- a/app/features/user-page/routes/u.$identifier.results.tsx +++ b/app/features/user-page/routes/u.$identifier.results.tsx @@ -8,6 +8,7 @@ import { Pagination } from "~/components/Pagination"; import { useUser } from "~/features/auth/core/user"; import { UserResultsTable } from "~/features/user-page/components/UserResultsTable"; import { useDebounce } from "~/hooks/useDebounce"; +import { useSearchParamPagination } from "~/hooks/useSearchParamPagination"; import invariant from "~/utils/invariant"; import { userPage, userResultsEditHighlightsPage } from "~/utils/urls"; import { SendouButton } from "../../../components/elements/Button"; @@ -58,12 +59,10 @@ export default function UserResultsPage() { [tournamentQuery], ); - const setPage = (page: number) => { - setSearchParams((params) => { - params.set("page", String(page)); - return params; - }); - }; + const pagination = useSearchParamPagination({ + currentPage: data.results.currentPage, + pagesCount: data.results.pagesCount, + }); return (
@@ -96,15 +95,7 @@ export default function UserResultsPage() {
- {data.results.pages > 1 ? ( - setPage(data.results.currentPage + 1)} - previousPage={() => setPage(data.results.currentPage - 1)} - setPage={setPage} - /> - ) : null} + {data.results.pagesCount > 1 ? : null} {data.hasHighlightedResults ? ( ; + return ; } function Results({ - seasonViewed, results, }: { - seasonViewed: number; results: UserSeasonsSetsLoaderData["results"]; }) { - const [, setSearchParams] = useSearchParams(); const ref = React.useRef(null); - const setPage = (page: number) => { - setSearchParams({ page: String(page), season: String(seasonViewed) }); - }; + const pagination = useSearchParamPagination({ + currentPage: results.currentPage, + pagesCount: results.pagesCount, + }); React.useEffect(() => { if (results.currentPage === 1) return; @@ -117,15 +115,7 @@ function Results({ ); })} - {results.pages > 1 ? ( - setPage(results.currentPage + 1)} - previousPage={() => setPage(results.currentPage - 1)} - setPage={(page) => setPage(page)} - /> - ) : null} + {results.pagesCount > 1 ? : null} ); diff --git a/app/features/user-page/routes/u.$identifier.vods.tsx b/app/features/user-page/routes/u.$identifier.vods.tsx index 858bd6393..3f04318f9 100644 --- a/app/features/user-page/routes/u.$identifier.vods.tsx +++ b/app/features/user-page/routes/u.$identifier.vods.tsx @@ -1,7 +1,8 @@ -import { useLoaderData, useMatches, useSearchParams } from "react-router"; +import { useLoaderData, useMatches } from "react-router"; import { Pagination } from "~/components/Pagination"; import { VodListing } from "~/features/vods/components/VodListing"; import styles from "~/features/vods/routes/vods.module.css"; +import { useSearchParamPagination } from "~/hooks/useSearchParamPagination"; import invariant from "~/utils/invariant"; import type { SendouRouteHandle } from "~/utils/remix.server"; import { userPage } from "~/utils/urls"; @@ -20,14 +21,11 @@ export default function UserVodsPage() { invariant(parentRoute); const data = useLoaderData(); const layoutData = parentRoute.loaderData as UserPageLoaderData; - const [, setSearchParams] = useSearchParams(); - const setPage = (page: number) => { - setSearchParams((params) => { - params.set("page", String(page)); - return params; - }); - }; + const pagination = useSearchParamPagination({ + currentPage: data.currentPage, + pagesCount: data.pagesCount, + }); return (
@@ -40,15 +38,7 @@ export default function UserVodsPage() { ))}
- {data.pagesCount > 1 ? ( - setPage(data.currentPage + 1)} - previousPage={() => setPage(data.currentPage - 1)} - setPage={setPage} - /> - ) : null} + {data.pagesCount > 1 ? : null} ); } diff --git a/app/features/vods/loaders/vods.server.ts b/app/features/vods/loaders/vods.server.ts index cec833eed..4dd90c80d 100644 --- a/app/features/vods/loaders/vods.server.ts +++ b/app/features/vods/loaders/vods.server.ts @@ -1,8 +1,5 @@ import type { LoaderFunctionArgs } from "react-router"; -import { - parseSearchParams, - redirectIfPageOutOfBounds, -} from "~/utils/remix.server"; +import { paginate, parseSearchParams } from "~/utils/remix.server"; import * as VodRepository from "../VodRepository.server"; import { VODS_PAGE_BATCH_SIZE } from "../vods-constants"; import { vodsSearchParamsSchema } from "../vods-schemas"; @@ -22,13 +19,8 @@ export const loader = async ({ request, url }: LoaderFunctionArgs) => { VodRepository.countVods(filters), ]); - const pagesCount = Math.max(1, Math.ceil(totalCount / VODS_PAGE_BATCH_SIZE)); - - redirectIfPageOutOfBounds({ url, page, pagesCount }); - return { vods, - currentPage: page, - pagesCount, + ...paginate({ url, page, pageSize: VODS_PAGE_BATCH_SIZE, totalCount }), }; }; diff --git a/app/features/vods/routes/vods.tsx b/app/features/vods/routes/vods.tsx index 8744204a0..fb6883657 100644 --- a/app/features/vods/routes/vods.tsx +++ b/app/features/vods/routes/vods.tsx @@ -5,6 +5,7 @@ import { Label } from "~/components/Label"; import { Main } from "~/components/Main"; import { Pagination } from "~/components/Pagination"; import { WeaponSelect } from "~/components/WeaponSelect"; +import { useSearchParamPagination } from "~/hooks/useSearchParamPagination"; import { modesShort } from "~/modules/in-game-lists/modes"; import { stageIds } from "~/modules/in-game-lists/stage-ids"; import { mainWeaponIds } from "~/modules/in-game-lists/weapon-ids"; @@ -50,12 +51,10 @@ export default function VodsSearchPage() { }); }; - const setPage = (page: number) => { - setSearchParams((params) => { - params.set("page", String(page)); - return params; - }); - }; + const pagination = useSearchParamPagination({ + currentPage: data.currentPage, + pagesCount: data.pagesCount, + }); return (
@@ -67,15 +66,7 @@ export default function VodsSearchPage() { ))} - {data.pagesCount > 1 ? ( - setPage(data.currentPage + 1)} - previousPage={() => setPage(data.currentPage - 1)} - setPage={setPage} - /> - ) : null} + {data.pagesCount > 1 ? : null} ) : (
{t("vods:noVods")}
diff --git a/app/hooks/useSearchParamPagination.ts b/app/hooks/useSearchParamPagination.ts new file mode 100644 index 000000000..89c2c293f --- /dev/null +++ b/app/hooks/useSearchParamPagination.ts @@ -0,0 +1,34 @@ +import { useSearchParams } from "react-router"; + +/** + * Pagination state for pages where the current page lives in the `page` search + * param and the loader takes care of slicing the results. + * + * Returns props that can be spread to the `` component. + * + * For paginating a list that is fully available on the client, see `usePagination`. + */ +export function useSearchParamPagination({ + currentPage, + pagesCount, +}: { + currentPage: number; + pagesCount: number; +}) { + const [, setSearchParams] = useSearchParams(); + + const setPage = (page: number) => { + setSearchParams((params) => { + params.set("page", String(page)); + return params; + }); + }; + + return { + currentPage, + pagesCount, + setPage, + nextPage: () => setPage(currentPage + 1), + previousPage: () => setPage(currentPage - 1), + }; +} diff --git a/app/utils/remix.server.test.ts b/app/utils/remix.server.test.ts index ced032f1f..a36b4dd51 100644 --- a/app/utils/remix.server.test.ts +++ b/app/utils/remix.server.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { redirectIfPageOutOfBounds } from "./remix.server"; +import { paginate } from "./remix.server"; const buildUrl = (url: string) => new URL(url); @@ -13,13 +13,25 @@ const captureRedirect = (fn: () => void) => { return null; }; -describe("redirectIfPageOutOfBounds()", () => { +describe("paginate()", () => { + it("returns the page count rounded up", () => { + const result = paginate({ + url: buildUrl("https://sendou.ink/vods?page=1"), + page: 1, + pageSize: 10, + totalCount: 41, + }); + + expect(result).toEqual({ currentPage: 1, pagesCount: 5 }); + }); + it("does not redirect when page is within bounds", () => { const response = captureRedirect(() => - redirectIfPageOutOfBounds({ + paginate({ url: buildUrl("https://sendou.ink/vods?page=2"), page: 2, - pagesCount: 5, + pageSize: 10, + totalCount: 50, }), ); @@ -28,10 +40,11 @@ describe("redirectIfPageOutOfBounds()", () => { it("does not redirect when page equals pagesCount", () => { const response = captureRedirect(() => - redirectIfPageOutOfBounds({ + paginate({ url: buildUrl("https://sendou.ink/vods?page=5"), page: 5, - pagesCount: 5, + pageSize: 10, + totalCount: 50, }), ); @@ -40,10 +53,11 @@ describe("redirectIfPageOutOfBounds()", () => { it("redirects to last page when page exceeds pagesCount", () => { const response = captureRedirect(() => - redirectIfPageOutOfBounds({ + paginate({ url: buildUrl("https://sendou.ink/vods?page=99"), page: 99, - pagesCount: 5, + pageSize: 10, + totalCount: 50, }), ); @@ -53,12 +67,13 @@ describe("redirectIfPageOutOfBounds()", () => { it("preserves other search params when redirecting", () => { const response = captureRedirect(() => - redirectIfPageOutOfBounds({ + paginate({ url: buildUrl( "https://sendou.ink/vods?type=TOURNAMENT&page=99&mode=SZ", ), page: 99, - pagesCount: 3, + pageSize: 10, + totalCount: 25, }), ); @@ -71,24 +86,24 @@ describe("redirectIfPageOutOfBounds()", () => { expect(locationUrl.searchParams.get("mode")).toBe("SZ"); }); - it("does not redirect on page 1 when pagesCount is 0 (empty results)", () => { - const response = captureRedirect(() => - redirectIfPageOutOfBounds({ - url: buildUrl("https://sendou.ink/vods?page=1"), - page: 1, - pagesCount: 0, - }), - ); + it("stays on page 1 when there are no results", () => { + const result = paginate({ + url: buildUrl("https://sendou.ink/vods?page=1"), + page: 1, + pageSize: 10, + totalCount: 0, + }); - expect(response).toBeNull(); + expect(result).toEqual({ currentPage: 1, pagesCount: 1 }); }); - it("redirects to page 1 when pagesCount is 0 and page exceeds 1", () => { + it("redirects to page 1 when there are no results and page exceeds 1", () => { const response = captureRedirect(() => - redirectIfPageOutOfBounds({ + paginate({ url: buildUrl("https://sendou.ink/vods?page=4"), page: 4, - pagesCount: 0, + pageSize: 10, + totalCount: 0, }), ); diff --git a/app/utils/remix.server.ts b/app/utils/remix.server.ts index fffda62f8..4513681ed 100644 --- a/app/utils/remix.server.ts +++ b/app/utils/remix.server.ts @@ -57,25 +57,33 @@ export function parseSearchParams({ } /** + * Resolves the pagination state of a loader whose current page comes from the + * `page` search param. `pagesCount` is at minimum 1 so empty result sets stay + * on page 1. + * * If the requested `page` exceeds `pagesCount`, throws a redirect to the last - * available page (preserving other search params). `pagesCount` is normalized - * to a minimum of 1 so empty result sets stay on page 1. + * available page (preserving other search params). */ -export function redirectIfPageOutOfBounds({ +export function paginate({ url, page, - pagesCount, + pageSize, + totalCount, }: { url: URL; page: number; - pagesCount: number; -}): void { - const safePagesCount = Math.max(1, pagesCount); - if (page <= safePagesCount) return; + pageSize: number; + totalCount: number; +}): { currentPage: number; pagesCount: number } { + const pagesCount = Math.max(1, Math.ceil(totalCount / pageSize)); - const searchParams = new URLSearchParams(url.searchParams); - searchParams.set("page", String(safePagesCount)); - throw redirect(`${url.pathname}?${searchParams.toString()}`); + if (page > pagesCount) { + const searchParams = new URLSearchParams(url.searchParams); + searchParams.set("page", String(pagesCount)); + throw redirect(`${url.pathname}?${searchParams.toString()}`); + } + + return { currentPage: page, pagesCount }; } export function parseSafeSearchParams({