mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-08 04:05:54 -05:00
Abstract pagination
This commit is contained in:
@@ -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 }),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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 }),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
34
app/hooks/useSearchParamPagination.ts
Normal file
34
app/hooks/useSearchParamPagination.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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>({
|
||||
|
||||
Reference in New Issue
Block a user