diff --git a/app/feature/search/components/SearchPagination.tsx b/app/feature/search/components/SearchPagination.tsx index 08b74ee..f12f99e 100644 --- a/app/feature/search/components/SearchPagination.tsx +++ b/app/feature/search/components/SearchPagination.tsx @@ -9,6 +9,7 @@ import Fa6SolidAngleLeft from "~icons/fa6-solid/angle-left"; import Fa6SolidAngleRight from "~icons/fa6-solid/angle-right"; import type { PaginationMeta } from "../../../generated/api/schemas/paginationMeta"; +import { buildPaginationMeta } from "../pagination"; import type { noteSearchParamSchema } from "../validation"; type PaginationProps = { @@ -28,16 +29,18 @@ export const SearchPagination = ({ visibleItemCount, ...groupProps }: PaginationProps) => { + const pagination = buildPaginationMeta(meta, currentQuery); + const pageFirstItemIndex = currentQuery.offset + 1; const totalDisplayedItems = currentQuery.offset + visibleItemCount; const prevTo = useMemo( - () => (meta?.prev ? withQuery("/", getQuery(meta.prev)) : null), - [meta?.prev], + () => (pagination?.prev ? withQuery("/", getQuery(pagination.prev)) : null), + [pagination?.prev], ); const nextTo = useMemo( - () => (meta?.next ? withQuery("/", getQuery(meta.next)) : null), - [meta?.next], + () => (pagination?.next ? withQuery("/", getQuery(pagination.next)) : null), + [pagination?.next], ); const [clickedButton, setClickedButton] = useState<"prev" | "next">(); diff --git a/app/feature/search/pagination.test.ts b/app/feature/search/pagination.test.ts new file mode 100644 index 0000000..ed71d05 --- /dev/null +++ b/app/feature/search/pagination.test.ts @@ -0,0 +1,85 @@ +import { getQuery } from "ufo"; +import { describe, expect, test } from "vitest"; +import type { z } from "zod"; + +import type { PaginationMeta } from "../../generated/api/schemas/paginationMeta"; +import { buildPaginationMeta } from "./pagination"; +import type { noteSearchParamSchema } from "./validation"; + +describe("buildPaginationMeta", () => { + test("API 修正前ロジック: 現在のクエリパラメータから PaginationMeta を生成できる", () => { + const currentQuery = { + post_includes_text: "地震", + limit: 10, + offset: 10, + } satisfies z.infer; + + // API が limit, offset 以外のクエリパラメータを削除してしまう挙動を再現 + const currentBrokenMeta = { + next: "https://example.com/api/v1/data/search?offset=20&limit=10", + prev: "https://example.com/api/v1/data/search?offset=0&limit=10", + } satisfies PaginationMeta; + + const paginationMeta = buildPaginationMeta(currentBrokenMeta, currentQuery); + const prevQuery = paginationMeta.prev + ? getQuery(paginationMeta.prev) + : null; + const nextQuery = paginationMeta.next + ? getQuery(paginationMeta.next) + : null; + + expect(prevQuery).toStrictEqual({ + limit: "10", + offset: "0", + post_includes_text: "地震", + }); + expect(nextQuery).toStrictEqual({ + limit: "10", + offset: "20", + post_includes_text: "地震", + }); + }); + + test("API 修正前ロジック: offset が 0 より大きい AND limit より小さい場合も prev を生成できる", () => { + const currentQuery = { + post_includes_text: "地震", + limit: 15, + offset: 10, + } satisfies z.infer; + + // API が limit, offset 以外のクエリパラメータを削除してしまう挙動を再現 + const currentBrokenMeta = { + next: "https://example.com/api/v1/data/search?offset=25&limit=15", + prev: "https://example.com/api/v1/data/search?offset=0&limit=15", + } satisfies PaginationMeta; + + const fixedMeta = buildPaginationMeta(currentBrokenMeta, currentQuery); + const prevQuery = fixedMeta.prev ? getQuery(fixedMeta.prev) : null; + + expect(prevQuery).toStrictEqual({ + limit: "15", + offset: "0", + post_includes_text: "地震", + }); + }); + + test("API 修正前ロジック: 前のページが存在しない場合に prev が null になる", () => { + const currentQuery = { + post_includes_text: "地震", + limit: 10, + offset: 0, + } satisfies z.infer; + + // API が limit, offset 以外のクエリパラメータを削除してしまう挙動を再現 + const currentBrokenMeta = { + next: "https://example.com/api/v1/data/search?offset=10&limit=10", + prev: null, + } satisfies PaginationMeta; + + const fixedMeta = buildPaginationMeta(currentBrokenMeta, currentQuery); + + const prevQuery = fixedMeta.prev ? getQuery(fixedMeta.prev) : null; + + expect(prevQuery).toBe(null); + }); +}); diff --git a/app/feature/search/pagination.ts b/app/feature/search/pagination.ts new file mode 100644 index 0000000..2d8ab2c --- /dev/null +++ b/app/feature/search/pagination.ts @@ -0,0 +1,50 @@ +import { parseURL, stringifyParsedURL, withQuery } from "ufo"; +import type { z } from "zod"; + +import type { PaginationMeta } from "../../generated/api/schemas/paginationMeta"; +import type { noteSearchParamSchema } from "./validation"; + +/** + * API 側のページネーション情報が正しい情報を返すようになるまで、現在のパラメータから次のページと前のページの URL を生成する + * @param meta + * API が返したページネーション情報。前後ページが存在するかどうかの確認にのみ使用する + * @param currentQuery + * 現在のクエリパラメータ + * @returns + * 修正後のページネーション情報 + */ +// API が正常な PaginationMeta を返すようになったらこの巻数で計算する必要はない +export const buildPaginationMeta = ( + meta: PaginationMeta, + currentQuery: z.infer, +): PaginationMeta => { + const { limit, offset, ...rest } = currentQuery; + + if (meta.next == null && meta.prev == null) { + return { + next: null, + prev: null, + }; + } + + const nextOffset = offset + limit; + + // offset が 0: 前のページが存在しない + // offset が 10, limit が 15: 前のページが存在する + const prevOffset = Math.max(offset - limit, 0); + + const isFirstPage = offset === 0; + + // 必ず prev か next が存在する + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const url = parseURL((meta.prev ?? meta.next)!); + url.search = ""; + const baseUrl = stringifyParsedURL(url); + + return { + next: withQuery(baseUrl, { ...rest, limit, offset: nextOffset }), + prev: isFirstPage + ? null + : withQuery(baseUrl, { ...rest, limit, offset: prevOffset }), + }; +};