From b81f90b730b30e755d9c30d5fde98ef75ded6ee8 Mon Sep 17 00:00:00 2001 From: Similoluwa Abidoye Date: Fri, 24 Jul 2026 10:54:57 +0100 Subject: [PATCH] feat(frontend): extract accessible pagination component for Transaction History Payment history previously fetched a hardcoded first page (LIMIT=50) and showed a static "End of list" message even though the backend already supports full page/limit/total_pages pagination. Extracts a pure, prop-driven TransactionHistoryPagination component (keyboard accessible, windowed page numbers, mobile-compact "Page X of Y" view) and wires real page navigation through the existing URL-driven filter state. A literal full React Server Component migration isn't compatible with this page's live WebSocket updates and instant client-side filtering, so the new component is deliberately data-fetching-free and controlled via props/ callback, making it trivially reusable if the page is ever split into a server shell + client island. Closes #1220 Co-Authored-By: Claude Sonnet 5 --- frontend/messages/en.json | 11 +- frontend/messages/es.json | 9 ++ frontend/messages/pt.json | 9 ++ .../(authenticated)/payment-history/page.tsx | 44 ++---- .../TransactionHistoryPagination.test.tsx | 113 ++++++++++++++ .../TransactionHistoryPagination.tsx | 142 ++++++++++++++++++ 6 files changed, 296 insertions(+), 32 deletions(-) create mode 100644 frontend/src/components/TransactionHistoryPagination.test.tsx create mode 100644 frontend/src/components/TransactionHistoryPagination.tsx diff --git a/frontend/messages/en.json b/frontend/messages/en.json index dc679ea4..53f741e3 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -214,6 +214,15 @@ "confirmed": "Confirmed", "failed": "Failed", "refunded": "Refunded" + }, + "pagination": { + "ariaLabel": "Payment history pagination", + "previous": "Previous page", + "next": "Next page", + "goToPage": "Go to page {page}", + "currentPage": "Current page, page {page}", + "pageLabel": "Page {page} of {totalPages}", + "range": "Showing {start}-{end} of {total}" } }, "walletSelector": { @@ -453,4 +462,4 @@ "notificationLabel": "Notification: {message}", "timestampLabel": "Timestamp: {timestamp}" } -} \ No newline at end of file +} diff --git a/frontend/messages/es.json b/frontend/messages/es.json index bfb03270..731ba479 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -214,6 +214,15 @@ "confirmed": "Confirmado", "failed": "Fallido", "refunded": "Reembolsado" + }, + "pagination": { + "ariaLabel": "Paginación del historial de pagos", + "previous": "Página anterior", + "next": "Página siguiente", + "goToPage": "Ir a la página {page}", + "currentPage": "Página actual, página {page}", + "pageLabel": "Página {page} de {totalPages}", + "range": "Mostrando {start}-{end} de {total}" } }, "walletSelector": { diff --git a/frontend/messages/pt.json b/frontend/messages/pt.json index a2df3ab8..866d882b 100644 --- a/frontend/messages/pt.json +++ b/frontend/messages/pt.json @@ -214,6 +214,15 @@ "confirmed": "Confirmado", "failed": "Falhou", "refunded": "Reembolsado" + }, + "pagination": { + "ariaLabel": "Paginação do histórico de pagamentos", + "previous": "Página anterior", + "next": "Próxima página", + "goToPage": "Ir para a página {page}", + "currentPage": "Página atual, página {page}", + "pageLabel": "Página {page} de {totalPages}", + "range": "Mostrando {start}-{end} de {total}" } }, "walletSelector": { diff --git a/frontend/src/app/(authenticated)/payment-history/page.tsx b/frontend/src/app/(authenticated)/payment-history/page.tsx index a18c9ca2..648ab1f7 100644 --- a/frontend/src/app/(authenticated)/payment-history/page.tsx +++ b/frontend/src/app/(authenticated)/payment-history/page.tsx @@ -6,6 +6,7 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { useLocale, useTranslations } from "next-intl"; import Skeleton from "react-loading-skeleton"; import "react-loading-skeleton/dist/skeleton.css"; +import TransactionHistoryPagination from "@/components/TransactionHistoryPagination"; import { localeToLanguageTag } from "@/i18n/config"; import { toast } from "sonner"; import { @@ -43,6 +44,7 @@ interface Payment { interface PaginatedResponse { payments: Payment[]; total_count: number; + total_pages: number; } const LIMIT = 50; @@ -115,6 +117,7 @@ export default function PaymentHistoryPage() { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [totalCount, setTotalCount] = useState(0); + const [totalPages, setTotalPages] = useState(0); const [selectedPayment, setSelectedPayment] = useState(null); const [hoveredPayment, setHoveredPayment] = useState(null); const [isModalOpen, setIsModalOpen] = useState(false); @@ -200,6 +203,7 @@ export default function PaymentHistoryPage() { const data: PaginatedResponse = await response.json(); setPayments(data.payments ?? []); setTotalCount(data.total_count ?? 0); + setTotalPages(data.total_pages ?? 0); } catch (err: unknown) { if (err instanceof Error && err.name === "AbortError") return; setError(err instanceof Error ? err.message : t("loadFailed")); @@ -217,9 +221,6 @@ export default function PaymentHistoryPage() { setSelectedPayment(paymentId); setIsSheetOpen(true); }; - const totalPages = Math.max(1, Math.ceil(totalCount / LIMIT)); - const pageStart = totalCount === 0 ? 0 : (currentPage - 1) * LIMIT + 1; - const pageEnd = Math.min(currentPage * LIMIT, totalCount); // ── Loading state ───────────────────────────────────────────────────────────── if (loading) { @@ -461,9 +462,7 @@ export default function PaymentHistoryPage() { {/* Results count */}

- {totalPages > 1 - ? `Showing ${pageStart}-${pageEnd} of ${totalCount}` - : t("showingResults", { shown: payments.length, total: totalCount })} + {t("showingResults", { shown: payments.length, total: totalCount })}

@@ -534,31 +533,14 @@ export default function PaymentHistoryPage() { )} - {totalPages > 1 && ( -
-

- Page {currentPage} of {totalPages} -

- -
- )} + diff --git a/frontend/src/components/TransactionHistoryPagination.test.tsx b/frontend/src/components/TransactionHistoryPagination.test.tsx new file mode 100644 index 00000000..56f56331 --- /dev/null +++ b/frontend/src/components/TransactionHistoryPagination.test.tsx @@ -0,0 +1,113 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import { describe, it, expect, vi } from "vitest"; +import "@testing-library/jest-dom/vitest"; +import { NextIntlClientProvider } from "next-intl"; +import TransactionHistoryPagination from "./TransactionHistoryPagination"; + +const messages = { + recentPayments: { + pagination: { + ariaLabel: "Payment history pagination", + previous: "Previous page", + next: "Next page", + goToPage: "Go to page {page}", + currentPage: "Current page, page {page}", + pageLabel: "Page {page} of {totalPages}", + range: "Showing {start}-{end} of {total}", + }, + }, +}; + +function renderPagination(props: Partial> = {}) { + const defaultProps = { + page: 1, + totalPages: 5, + totalCount: 220, + limit: 50, + onPageChange: vi.fn(), + }; + const merged = { ...defaultProps, ...props }; + + render( + + + , + ); + + return merged; +} + +describe("TransactionHistoryPagination", () => { + it("renders nothing when there is only one page", () => { + const { container } = render( + + + , + ); + expect(container).toBeEmptyDOMElement(); + }); + + it("renders nothing when there are zero pages", () => { + const { container } = render( + + + , + ); + expect(container).toBeEmptyDOMElement(); + }); + + it("shows the correct result range", () => { + renderPagination({ page: 2, totalPages: 5, totalCount: 220, limit: 50 }); + expect(screen.getByText("Showing 51-100 of 220")).toBeInTheDocument(); + }); + + it("disables the previous button on the first page", () => { + renderPagination({ page: 1 }); + expect(screen.getByRole("button", { name: "Previous page" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Next page" })).toBeEnabled(); + }); + + it("disables the next button on the last page", () => { + renderPagination({ page: 5, totalPages: 5 }); + expect(screen.getByRole("button", { name: "Next page" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Previous page" })).toBeEnabled(); + }); + + it("calls onPageChange with the next page when Next is clicked", () => { + const { onPageChange } = renderPagination({ page: 2, totalPages: 5 }); + fireEvent.click(screen.getByRole("button", { name: "Next page" })); + expect(onPageChange).toHaveBeenCalledWith(3); + }); + + it("calls onPageChange with the previous page when Previous is clicked", () => { + const { onPageChange } = renderPagination({ page: 2, totalPages: 5 }); + fireEvent.click(screen.getByRole("button", { name: "Previous page" })); + expect(onPageChange).toHaveBeenCalledWith(1); + }); + + it("calls onPageChange with a specific page number when clicked", () => { + const { onPageChange } = renderPagination({ page: 1, totalPages: 5 }); + fireEvent.click(screen.getByRole("button", { name: "Go to page 3" })); + expect(onPageChange).toHaveBeenCalledWith(3); + }); + + it("marks the current page with aria-current", () => { + renderPagination({ page: 3, totalPages: 5 }); + const current = screen.getByRole("button", { name: "Current page, page 3" }); + expect(current).toHaveAttribute("aria-current", "page"); + }); + + it("collapses long page ranges with an ellipsis", () => { + renderPagination({ page: 5, totalPages: 12 }); + expect(screen.getAllByText("…").length).toBeGreaterThan(0); + expect(screen.getByRole("button", { name: "Go to page 1" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Go to page 12" })).toBeInTheDocument(); + }); + + it("disables all controls when disabled prop is set", () => { + renderPagination({ page: 2, totalPages: 5, disabled: true }); + expect(screen.getByRole("button", { name: "Previous page" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Next page" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Go to page 1" })).toBeDisabled(); + }); +}); diff --git a/frontend/src/components/TransactionHistoryPagination.tsx b/frontend/src/components/TransactionHistoryPagination.tsx new file mode 100644 index 00000000..cc5148b2 --- /dev/null +++ b/frontend/src/components/TransactionHistoryPagination.tsx @@ -0,0 +1,142 @@ +"use client"; + +import { useTranslations } from "next-intl"; + +interface TransactionHistoryPaginationProps { + page: number; + totalPages: number; + totalCount: number; + limit: number; + onPageChange: (page: number) => void; + disabled?: boolean; +} + +const SIBLING_COUNT = 1; + +/** + * Builds a windowed page list with `null` standing in for an ellipsis, e.g. + * [1, null, 4, 5, 6, null, 12] for page=5, totalPages=12. + */ +function buildPageWindow(page: number, totalPages: number): (number | null)[] { + const totalNumbers = SIBLING_COUNT * 2 + 5; // first + last + current + 2 siblings + 2 ellipses + if (totalPages <= totalNumbers) { + return Array.from({ length: totalPages }, (_, i) => i + 1); + } + + const leftSibling = Math.max(page - SIBLING_COUNT, 1); + const rightSibling = Math.min(page + SIBLING_COUNT, totalPages); + + const showLeftEllipsis = leftSibling > 2; + const showRightEllipsis = rightSibling < totalPages - 1; + + const pages: (number | null)[] = [1]; + + if (showLeftEllipsis) pages.push(null); + for (let p = Math.max(leftSibling, 2); p <= Math.min(rightSibling, totalPages - 1); p++) { + pages.push(p); + } + if (showRightEllipsis) pages.push(null); + + pages.push(totalPages); + + return pages; +} + +/** + * Pure, presentational pagination control for the payment history table. + * Deliberately holds no data-fetching or URL logic of its own (all state is + * passed in via props / raised via `onPageChange`) so it stays trivially + * composable if the parent page is ever split into a server shell + client + * island — the page itself can't be a full RSC today because it depends on + * live WebSocket updates and instant client-side filtering. + */ +export default function TransactionHistoryPagination({ + page, + totalPages, + totalCount, + limit, + onPageChange, + disabled = false, +}: TransactionHistoryPaginationProps) { + const t = useTranslations("recentPayments.pagination"); + + if (totalPages <= 1) return null; + + const canGoPrevious = page > 1; + const canGoNext = page < totalPages; + const rangeStart = (page - 1) * limit + 1; + const rangeEnd = Math.min(page * limit, totalCount); + const pageWindow = buildPageWindow(page, totalPages); + + const baseButtonClasses = + "inline-flex h-9 min-w-9 items-center justify-center rounded-lg px-2.5 text-xs font-bold transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--pluto-500)] focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-40"; + + return ( + + ); +}