Abstract pagination

This commit is contained in:
Kalle
2026-07-26 12:34:03 +03:00
parent 0c5d9d1136
commit a28819654c
13 changed files with 133 additions and 147 deletions

View File

@@ -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 }),
},
};
};

View File

@@ -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<TournamentAdminAuditLoader>();
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 (
<div className="stack md">
@@ -62,15 +60,7 @@ export default function TournamentAdminAuditLog() {
))}
</tbody>
</Table>
{auditLog.pagesCount > 1 ? (
<Pagination
currentPage={auditLog.currentPage}
pagesCount={auditLog.pagesCount}
nextPage={() => setPage(auditLog.currentPage + 1)}
previousPage={() => setPage(auditLog.currentPage - 1)}
setPage={setPage}
/>
) : null}
{auditLog.pagesCount > 1 ? <Pagination {...pagination} /> : null}
</>
)}
</div>

View File

@@ -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,
};

View File

@@ -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,
}),

View File

@@ -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 }),
};
};

View File

@@ -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 (
<div className="stack lg">
@@ -96,15 +95,7 @@ export default function UserResultsPage() {
</div>
</div>
<UserResultsTable id="user-results-table" results={data.results.value} />
{data.results.pages > 1 ? (
<Pagination
currentPage={data.results.currentPage}
pagesCount={data.results.pages}
nextPage={() => setPage(data.results.currentPage + 1)}
previousPage={() => setPage(data.results.currentPage - 1)}
setPage={setPage}
/>
) : null}
{data.results.pagesCount > 1 ? <Pagination {...pagination} /> : null}
{data.hasHighlightedResults ? (
<SendouButton
variant="minimal"

View File

@@ -6,7 +6,6 @@ import {
type ShouldRevalidateFunction,
useLoaderData,
useMatches,
useSearchParams,
} from "react-router";
import { Avatar } from "~/components/Avatar";
import { WeaponImage } from "~/components/Image";
@@ -16,6 +15,7 @@ import type {
SeasonGroupMatch,
SeasonTournamentResult,
} from "~/features/sendouq-match/SQMatchRepository.server";
import { useSearchParamPagination } from "~/hooks/useSearchParamPagination";
import { databaseTimestampToDate } from "~/utils/dates";
import invariant from "~/utils/invariant";
import { roundToNDecimalPlaces } from "~/utils/number";
@@ -57,22 +57,20 @@ export default function UserSeasonsSets() {
);
}
return <Results results={data.results} seasonViewed={data.season} />;
return <Results results={data.results} />;
}
function Results({
seasonViewed,
results,
}: {
seasonViewed: number;
results: UserSeasonsSetsLoaderData["results"];
}) {
const [, setSearchParams] = useSearchParams();
const ref = React.useRef<HTMLDivElement>(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({
);
})}
</div>
{results.pages > 1 ? (
<Pagination
currentPage={results.currentPage}
pagesCount={results.pages}
nextPage={() => setPage(results.currentPage + 1)}
previousPage={() => setPage(results.currentPage - 1)}
setPage={(page) => setPage(page)}
/>
) : null}
{results.pagesCount > 1 ? <Pagination {...pagination} /> : null}
</div>
</div>
);

View File

@@ -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<typeof loader>();
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 (
<div className="stack md">
@@ -40,15 +38,7 @@ export default function UserVodsPage() {
<VodListing key={vod.id} vod={vod} showUser={false} />
))}
</div>
{data.pagesCount > 1 ? (
<Pagination
currentPage={data.currentPage}
pagesCount={data.pagesCount}
nextPage={() => setPage(data.currentPage + 1)}
previousPage={() => setPage(data.currentPage - 1)}
setPage={setPage}
/>
) : null}
{data.pagesCount > 1 ? <Pagination {...pagination} /> : null}
</div>
);
}

View File

@@ -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 }),
};
};

View File

@@ -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 (
<Main className="stack lg" bigger>
@@ -67,15 +66,7 @@ export default function VodsSearchPage() {
<VodListing key={vod.id} vod={vod} />
))}
</div>
{data.pagesCount > 1 ? (
<Pagination
currentPage={data.currentPage}
pagesCount={data.pagesCount}
nextPage={() => setPage(data.currentPage + 1)}
previousPage={() => setPage(data.currentPage - 1)}
setPage={setPage}
/>
) : null}
{data.pagesCount > 1 ? <Pagination {...pagination} /> : null}
</>
) : (
<div className="text-lg text-lighter">{t("vods:noVods")}</div>

View File

@@ -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 `<Pagination />` 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),
};
}

View File

@@ -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,
}),
);

View File

@@ -57,25 +57,33 @@ export function parseSearchParams<T extends z.ZodTypeAny>({
}
/**
* 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<T extends z.ZodTypeAny>({