From 9e87694f0d5f26a5723209c7e36cb9717921eb02 Mon Sep 17 00:00:00 2001 From: Simon Enerstrand Date: Thu, 17 Sep 2026 20:03:27 +0200 Subject: [PATCH] fix(dashboard): use client-side navigation in pagination to prevent full page reloads (#2305) --- .../web/__tests__/unit/cap-pagination.test.ts | 88 +++++++++++++ .../caps/components/CapPagination.tsx | 38 ++++-- packages/ui/src/components/Pagination.tsx | 120 +++++++++++++----- 3 files changed, 199 insertions(+), 47 deletions(-) create mode 100644 apps/web/__tests__/unit/cap-pagination.test.ts diff --git a/apps/web/__tests__/unit/cap-pagination.test.ts b/apps/web/__tests__/unit/cap-pagination.test.ts new file mode 100644 index 00000000000..85d7182cdf7 --- /dev/null +++ b/apps/web/__tests__/unit/cap-pagination.test.ts @@ -0,0 +1,88 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import React from "react"; +import { describe, expect, it } from "vitest"; +import { CapPagination } from "@/app/(org)/dashboard/caps/components/CapPagination"; +import { + Pagination, + PaginationContent, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, +} from "@cap/ui"; + +describe("CapPagination and Client Navigation (Issue #2305)", () => { + it("verifies CapPagination imports Link from next/link and passes asChild", () => { + const filePath = join( + process.cwd(), + "app/(org)/dashboard/caps/components/CapPagination.tsx", + ); + const content = readFileSync(filePath, "utf-8"); + + expect(content).toContain('import Link from "next/link";'); + expect(content).toContain("asChild"); + expect(content).toContain("scroll={false}"); + }); + + it("verifies @cap/ui Pagination.tsx supports asChild via Radix Slot", () => { + const filePath = join( + process.cwd(), + "../../packages/ui/src/components/Pagination.tsx", + ); + const content = readFileSync(filePath, "utf-8"); + + expect(content).toContain('import { Slot } from "@radix-ui/react-slot";'); + expect(content).toContain("asChild?: boolean;"); + expect(content).toContain('const Comp = asChild ? Slot : "a";'); + }); + + it("renders client Link elements inside PaginationPrevious, PaginationLink, and PaginationNext", () => { + const element = CapPagination({ + currentPage: 2, + totalPages: 5, + }) as React.ReactElement<{ children: React.ReactElement<{ children: React.ReactNode[] }> }>; + + expect(element).toBeDefined(); + expect(element.type).toBe(Pagination); + + const content = element.props.children; + expect(content.type).toBe(PaginationContent); + + const items = React.Children.toArray(content.props.children) as React.ReactElement[]; + expect(items.length).toBeGreaterThan(0); + + // First item should be PaginationPrevious with asChild + const prevItem = items[0].props.children as React.ReactElement<{ asChild?: boolean; children: React.ReactElement<{ href: string; scroll: boolean }> }>; + expect(prevItem.type).toBe(PaginationPrevious); + expect(prevItem.props.asChild).toBe(true); + expect(prevItem.props.children.props.href).toBe("/dashboard/caps?page=1"); + expect(prevItem.props.children.props.scroll).toBe(false); + + // Last item should be PaginationNext with asChild + const nextItem = items[items.length - 1].props.children as React.ReactElement<{ asChild?: boolean; children: React.ReactElement<{ href: string; scroll: boolean }> }>; + expect(nextItem.type).toBe(PaginationNext); + expect(nextItem.props.asChild).toBe(true); + expect(nextItem.props.children.props.href).toBe("/dashboard/caps?page=3"); + expect(nextItem.props.children.props.scroll).toBe(false); + }); + + it("respects custom hrefForPage generator with client navigation", () => { + const customHref = (page: number) => `/custom/route?p=${page}`; + const element = CapPagination({ + currentPage: 1, + totalPages: 3, + hrefForPage: customHref, + }) as React.ReactElement<{ children: React.ReactElement<{ children: React.ReactNode[] }> }>; + + const content = element.props.children; + const items = React.Children.toArray(content.props.children) as React.ReactElement[]; + + // On page 1, first link is page 1 (no Previous) + const firstLink = items[0].props.children as React.ReactElement<{ asChild?: boolean; children: React.ReactElement<{ href: string; scroll: boolean }> }>; + expect(firstLink.type).toBe(PaginationLink); + expect(firstLink.props.asChild).toBe(true); + expect(firstLink.props.children.props.href).toBe("/custom/route?p=1"); + expect(firstLink.props.children.props.scroll).toBe(false); + }); +}); diff --git a/apps/web/app/(org)/dashboard/caps/components/CapPagination.tsx b/apps/web/app/(org)/dashboard/caps/components/CapPagination.tsx index 10ccca102c6..588262c489c 100644 --- a/apps/web/app/(org)/dashboard/caps/components/CapPagination.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/CapPagination.tsx @@ -7,6 +7,7 @@ import { PaginationNext, PaginationPrevious, } from "@cap/ui"; +import Link from "next/link"; interface CapPaginationProps { currentPage: number; @@ -25,50 +26,63 @@ export const CapPagination: React.FC = ({ {currentPage > 1 && ( + > + + )} - 1 + + 1 + {currentPage !== 1 && ( - {currentPage} + + {currentPage} + )} {totalPages > currentPage + 1 && ( - {currentPage + 1} + + {currentPage + 1} + )} {currentPage > 2 && } + > + + diff --git a/packages/ui/src/components/Pagination.tsx b/packages/ui/src/components/Pagination.tsx index 0455869f3d7..aa20a278ab5 100644 --- a/packages/ui/src/components/Pagination.tsx +++ b/packages/ui/src/components/Pagination.tsx @@ -1,4 +1,5 @@ import { classNames } from "@cap/utils"; +import { Slot } from "@radix-ui/react-slot"; import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react"; import * as React from "react"; import { type ButtonProps, buttonVariants } from "./Button"; @@ -34,6 +35,7 @@ PaginationItem.displayName = "PaginationItem"; type PaginationLinkProps = { isActive?: boolean; + asChild?: boolean; } & Pick & React.ComponentProps<"a">; @@ -41,52 +43,100 @@ const PaginationLink = ({ className, isActive, size = "md", + asChild = false, ...props -}: PaginationLinkProps) => ( - -); +}: PaginationLinkProps) => { + const Comp = asChild ? Slot : "a"; + return ( + + ); +}; PaginationLink.displayName = "PaginationLink"; const PaginationPrevious = ({ className, + children, ...props -}: React.ComponentProps) => ( - - -

Previous

-
-); +}: React.ComponentProps) => { + let content = children; + if (props.asChild && React.isValidElement(children)) { + const child = children as React.ReactElement<{ children?: React.ReactNode }>; + if (!child.props.children) { + content = React.cloneElement(child, {}, ( + <> + +

Previous

+ + )); + } + } else if (!children) { + content = ( + <> + +

Previous

+ + ); + } + + return ( + + {content} + + ); +}; PaginationPrevious.displayName = "PaginationPrevious"; const PaginationNext = ({ className, + children, ...props -}: React.ComponentProps) => ( - -

Next

- -
-); +}: React.ComponentProps) => { + let content = children; + if (props.asChild && React.isValidElement(children)) { + const child = children as React.ReactElement<{ children?: React.ReactNode }>; + if (!child.props.children) { + content = React.cloneElement(child, {}, ( + <> +

Next

+ + + )); + } + } else if (!children) { + content = ( + <> +

Next

+ + + ); + } + + return ( + + {content} + + ); +}; PaginationNext.displayName = "PaginationNext"; const PaginationEllipsis = ({