diff --git a/frontend/src/components/AppSettings/Deployments.tsx b/frontend/src/components/AppSettings/Deployments.tsx
index 0538001b4d..1dbaddadb3 100644
--- a/frontend/src/components/AppSettings/Deployments.tsx
+++ b/frontend/src/components/AppSettings/Deployments.tsx
@@ -15,7 +15,7 @@ const Deployments = ({
<>
{deployments?.length > 0 ? (
<>
-
+
{headers.map((header) => (
diff --git a/frontend/src/components/Billing/Billing.tsx b/frontend/src/components/Billing/Billing.tsx
index 5efb7b0925..2f68111f9d 100644
--- a/frontend/src/components/Billing/Billing.tsx
+++ b/frontend/src/components/Billing/Billing.tsx
@@ -24,7 +24,7 @@ function BillingTableBody() {
]
return (
-
+
{data.map((item, index) => (
{item}
))}
diff --git a/frontend/src/components/Common/QuickStart.tsx b/frontend/src/components/Common/QuickStart.tsx
index 209453f3bb..f7419e6c3f 100644
--- a/frontend/src/components/Common/QuickStart.tsx
+++ b/frontend/src/components/Common/QuickStart.tsx
@@ -24,10 +24,7 @@ const QuickStart = () => {
You can learn more in the{" "}
-
- FastAPI CLI documentation
-
- .
+ FastAPI CLI documentation.
diff --git a/frontend/src/components/Invitations/Invitations.tsx b/frontend/src/components/Invitations/Invitations.tsx
index 048c7931ea..27cb7295af 100644
--- a/frontend/src/components/Invitations/Invitations.tsx
+++ b/frontend/src/components/Invitations/Invitations.tsx
@@ -1,11 +1,24 @@
-import { Badge, Box, Center, Container, Flex, Table } from "@chakra-ui/react"
-import { useQuery, useQueryClient } from "@tanstack/react-query"
-import { useEffect, useState } from "react"
+import {
+ Badge,
+ Box,
+ Center,
+ Container,
+ Flex,
+ HStack,
+ Table,
+} from "@chakra-ui/react"
+import { useQuery } from "@tanstack/react-query"
+import { useState } from "react"
import { ErrorBoundary } from "react-error-boundary"
import { EmailPending } from "@/assets/icons"
import { InvitationsService } from "@/client/services"
-import { Button } from "@/components//ui/button"
+import {
+ PaginationItems,
+ PaginationNextTrigger,
+ PaginationPrevTrigger,
+ PaginationRoot,
+} from "@/components/ui/pagination"
import { Skeleton } from "@/components/ui/skeleton"
import EmptyState from "../Common/EmptyState"
import CancelInvitation from "./CancelInvitation"
@@ -27,7 +40,6 @@ const getInvitationsQueryOptions = ({
})
function Invitations({ teamId }: { teamId: string }) {
- const queryClient = useQueryClient()
const [page, setPage] = useState(1)
const {
data: invitations,
@@ -38,18 +50,8 @@ function Invitations({ teamId }: { teamId: string }) {
placeholderData: (previous) => previous,
})
- const hasNextPage = invitations?.data.length === PER_PAGE + 1
const invitationsData = invitations?.data.slice(0, PER_PAGE)
- const hasPreviousPage = page > 1
-
- // biome-ignore lint/correctness/useExhaustiveDependencies(a): getInvitationsQueryOptions does not need to be included in the dependencies
- useEffect(() => {
- if (hasNextPage) {
- queryClient.prefetchQuery(
- getInvitationsQueryOptions({ teamId, page: page + 1 }),
- )
- }
- }, [page, queryClient, hasNextPage, teamId])
+ const invitationsCount = invitations?.count ?? 0
const headers = ["Email", "Status", "Actions"]
@@ -57,7 +59,11 @@ function Invitations({ teamId }: { teamId: string }) {
<>
{(invitationsData?.length ?? 0) > 0 ? (
-
+
{headers.map((header) => (
@@ -83,9 +89,8 @@ function Invitations({ teamId }: { teamId: string }) {
)}
>
- {isLoading ? (
- <>
- {new Array(3).fill(null).map((_, index) => (
+ {isLoading
+ ? new Array(3).fill(null).map((_, index) => (
@@ -93,49 +98,36 @@ function Invitations({ teamId }: { teamId: string }) {
+ ))
+ : invitationsData?.map(({ id, status, email }) => (
+
+
+ {email}
+
+
+ {status}
+
+
+
+
+
))}
- >
- ) : (
- invitationsData?.map(({ id, status, email }) => (
-
-
- {email}
-
-
- {status}
-
-
-
-
-
- ))
- )}
- {(hasPreviousPage || hasNextPage) && (
-
+ setPage(page)}
>
-
- Page {page}
-
-
- )}
+
+
+
+
+
+
+
) : (
diff --git a/frontend/src/components/Teams/Team.tsx b/frontend/src/components/Teams/Team.tsx
index cd2847bad7..e35a7543be 100644
--- a/frontend/src/components/Teams/Team.tsx
+++ b/frontend/src/components/Teams/Team.tsx
@@ -1,13 +1,26 @@
-import { Badge, Box, Container, Flex, Skeleton, Table } from "@chakra-ui/react"
+import {
+ Badge,
+ Box,
+ Container,
+ Flex,
+ HStack,
+ Skeleton,
+ Table,
+} from "@chakra-ui/react"
import { useSuspenseQuery } from "@tanstack/react-query"
import { Suspense, useState } from "react"
import { ErrorBoundary } from "react-error-boundary"
-import { useCurrentUser } from "../../hooks/useAuth"
-import { Route } from "../../routes/_layout/$team"
-import { fetchTeamBySlug, getCurrentUserRole } from "../../utils"
+import {
+ PaginationItems,
+ PaginationNextTrigger,
+ PaginationPrevTrigger,
+ PaginationRoot,
+} from "@/components/ui/pagination"
+import { useCurrentUser } from "@/hooks/useAuth"
+import { Route } from "@/routes/_layout/$team"
+import { fetchTeamBySlug, getCurrentUserRole } from "@/utils"
import ActionsMenu from "../Common/ActionsMenu"
-import { Button } from "../ui/button"
const PER_PAGE = 5
@@ -21,9 +34,7 @@ function Team() {
})
const members = team.user_links.slice((page - 1) * PER_PAGE, page * PER_PAGE)
- const hasNextPage = team.user_links.length > page * PER_PAGE
- const hasPreviousPage = page > 1
-
+ const membersCount = team.user_links.length
const currentUserRole = getCurrentUserRole(team, currentUser)
const headers = ["Email", "Role"]
@@ -33,7 +44,7 @@ function Team() {
return (
-
+
{headers.map((header) => (
@@ -99,26 +110,19 @@ function Team() {
- {(hasPreviousPage || hasNextPage) && (
-
+ setPage(page)}
>
-
- Page {page}
-
-
- )}
+
+
+
+
+
+
+
)
}
diff --git a/frontend/src/components/ui/link-button.tsx b/frontend/src/components/ui/link-button.tsx
new file mode 100644
index 0000000000..defa1c3776
--- /dev/null
+++ b/frontend/src/components/ui/link-button.tsx
@@ -0,0 +1,12 @@
+"use client"
+
+import type { HTMLChakraProps, RecipeProps } from "@chakra-ui/react"
+import { createRecipeContext } from "@chakra-ui/react"
+
+export interface LinkButtonProps
+ extends HTMLChakraProps<"a", RecipeProps<"button">> {}
+
+const { withContext } = createRecipeContext({ key: "button" })
+
+// Replace "a" with your framework's link component
+export const LinkButton = withContext("a")
diff --git a/frontend/src/components/ui/pagination.tsx b/frontend/src/components/ui/pagination.tsx
new file mode 100644
index 0000000000..8201ed848f
--- /dev/null
+++ b/frontend/src/components/ui/pagination.tsx
@@ -0,0 +1,208 @@
+"use client"
+
+import type { ButtonProps, TextProps } from "@chakra-ui/react"
+import {
+ Button,
+ Pagination as ChakraPagination,
+ IconButton,
+ Text,
+ createContext,
+ usePaginationContext,
+} from "@chakra-ui/react"
+import * as React from "react"
+import {
+ HiChevronLeft,
+ HiChevronRight,
+ HiMiniEllipsisHorizontal,
+} from "react-icons/hi2"
+import { LinkButton } from "./link-button"
+
+interface ButtonVariantMap {
+ current: ButtonProps["variant"]
+ default: ButtonProps["variant"]
+ ellipsis: ButtonProps["variant"]
+}
+
+type PaginationVariant = "outline" | "solid" | "subtle"
+
+interface ButtonVariantContext {
+ size: ButtonProps["size"]
+ variantMap: ButtonVariantMap
+ getHref?: (page: number) => string
+}
+
+const [RootPropsProvider, useRootProps] = createContext({
+ name: "RootPropsProvider",
+})
+
+export interface PaginationRootProps
+ extends Omit {
+ size?: ButtonProps["size"]
+ variant?: PaginationVariant
+ getHref?: (page: number) => string
+}
+
+const variantMap: Record = {
+ outline: { default: "ghost", ellipsis: "plain", current: "outline" },
+ solid: { default: "outline", ellipsis: "outline", current: "solid" },
+ subtle: { default: "ghost", ellipsis: "plain", current: "subtle" },
+}
+
+export const PaginationRoot = React.forwardRef<
+ HTMLDivElement,
+ PaginationRootProps
+>(function PaginationRoot(props, ref) {
+ const { size = "sm", variant = "outline", getHref, ...rest } = props
+ return (
+
+
+
+ )
+})
+
+export const PaginationEllipsis = React.forwardRef<
+ HTMLDivElement,
+ ChakraPagination.EllipsisProps
+>(function PaginationEllipsis(props, ref) {
+ const { size, variantMap } = useRootProps()
+ return (
+
+
+
+ )
+})
+
+export const PaginationItem = React.forwardRef<
+ HTMLButtonElement,
+ ChakraPagination.ItemProps
+>(function PaginationItem(props, ref) {
+ const { page } = usePaginationContext()
+ const { size, variantMap, getHref } = useRootProps()
+
+ const current = page === props.value
+ const variant = current ? variantMap.current : variantMap.default
+
+ if (getHref) {
+ return (
+
+ {props.value}
+
+ )
+ }
+
+ return (
+
+
+
+ )
+})
+
+export const PaginationPrevTrigger = React.forwardRef<
+ HTMLButtonElement,
+ ChakraPagination.PrevTriggerProps
+>(function PaginationPrevTrigger(props, ref) {
+ const { size, variantMap, getHref } = useRootProps()
+ const { previousPage } = usePaginationContext()
+
+ if (getHref) {
+ return (
+
+
+
+ )
+ }
+
+ return (
+
+
+
+
+
+ )
+})
+
+export const PaginationNextTrigger = React.forwardRef<
+ HTMLButtonElement,
+ ChakraPagination.NextTriggerProps
+>(function PaginationNextTrigger(props, ref) {
+ const { size, variantMap, getHref } = useRootProps()
+ const { nextPage } = usePaginationContext()
+
+ if (getHref) {
+ return (
+
+
+
+ )
+ }
+
+ return (
+
+
+
+
+
+ )
+})
+
+export const PaginationItems = (props: React.HTMLAttributes) => {
+ return (
+
+ {({ pages }) =>
+ pages.map((page, index) => {
+ return page.type === "ellipsis" ? (
+
+ ) : (
+
+ )
+ })
+ }
+
+ )
+}
+
+interface PageTextProps extends TextProps {
+ format?: "short" | "compact" | "long"
+}
+
+export const PaginationPageText = React.forwardRef<
+ HTMLParagraphElement,
+ PageTextProps
+>(function PaginationPageText(props, ref) {
+ const { format = "compact", ...rest } = props
+ const { page, totalPages, pageRange, count } = usePaginationContext()
+ const content = React.useMemo(() => {
+ if (format === "short") return `${page} / ${totalPages}`
+ if (format === "compact") return `${page} of ${totalPages}`
+ return `${pageRange.start + 1} - ${pageRange.end} of ${count}`
+ }, [format, page, totalPages, pageRange, count])
+
+ return (
+
+ {content}
+
+ )
+})
diff --git a/frontend/src/routes/_layout/$team/apps/index.tsx b/frontend/src/routes/_layout/$team/apps/index.tsx
index 3976560956..314e7972d0 100644
--- a/frontend/src/routes/_layout/$team/apps/index.tsx
+++ b/frontend/src/routes/_layout/$team/apps/index.tsx
@@ -6,14 +6,17 @@ import {
} from "@tanstack/react-router"
import { z } from "zod"
-import CustomCard from "@/components/Common/CustomCard"
-import { useQueryClient } from "@tanstack/react-query"
-import { useEffect } from "react"
-
import { EmptyBox } from "@/assets/icons"
import { AppsService } from "@/client"
+import CustomCard from "@/components/Common/CustomCard"
import EmptyState from "@/components/Common/EmptyState"
import QuickStart from "@/components/Common/QuickStart"
+import {
+ PaginationItems,
+ PaginationNextTrigger,
+ PaginationPrevTrigger,
+ PaginationRoot,
+} from "@/components/ui/pagination"
import { fetchTeamBySlug } from "@/utils"
const appsSearchSchema = z.object({
@@ -39,8 +42,7 @@ function getAppsQueryOptions({
queryFn: () =>
AppsService.readApps({
skip: (page - 1) * PER_PAGE,
- // Fetching one extra to determine if there's a next page
- limit: PER_PAGE + 1,
+ limit: PER_PAGE,
orderBy,
order,
teamId,
@@ -79,37 +81,18 @@ export const Route = createFileRoute("/_layout/$team/apps/")({
function Apps() {
const headers = ["name", "slug", "created at"]
- const { page = 1, order, orderBy } = Route.useSearch()
const navigate = useNavigate({ from: Route.fullPath })
const setPage = (page: number) =>
navigate({
search: (prev: { [key: string]: string }) => ({ ...prev, page }),
})
- const queryClient = useQueryClient()
const {
- apps: { data },
- team,
+ apps: { data, count },
} = Route.useLoaderData()
- const hasNextPage = data.length === PER_PAGE + 1
const apps = data.slice(0, PER_PAGE)
- const hasPreviousPage = page > 1
-
- useEffect(() => {
- if (hasNextPage) {
- queryClient.prefetchQuery(
- getAppsQueryOptions({
- page: page + 1,
- orderBy,
- order,
- teamId: team.id,
- }),
- )
- }
- }, [page, queryClient, hasNextPage, order, orderBy, team.id])
-
return (
@@ -124,7 +107,11 @@ function Apps() {
{apps?.length > 0 ? (
<>
-
+
{headers.map((header) => (
@@ -139,51 +126,40 @@ function Apps() {
- {apps.map((app) => (
-
+ {apps.map(({ id, name, slug, created_at }) => (
+
- {/* TODO: Add hover */}
- {app.name}
+ {name}
- {app.slug}
+ {slug}
- {new Date(app.created_at).toLocaleString()}
+ {new Date(created_at).toLocaleString()}
))}
- {(hasPreviousPage || hasNextPage) && (
-
+ setPage(page)}
>
-
- Page {page}
-
-
- )}
+
+
+
+
+
+
+
>
) : (
diff --git a/frontend/src/routes/_layout/teams/all.tsx b/frontend/src/routes/_layout/teams/all.tsx
index 852ae7e6d3..439e02496a 100644
--- a/frontend/src/routes/_layout/teams/all.tsx
+++ b/frontend/src/routes/_layout/teams/all.tsx
@@ -1,6 +1,3 @@
-import { createFileRoute } from "@tanstack/react-router"
-import { z } from "zod"
-
import {
Badge,
Box,
@@ -10,14 +7,19 @@ import {
Table,
Text,
} from "@chakra-ui/react"
-import { useQueryClient } from "@tanstack/react-query"
+import { createFileRoute } from "@tanstack/react-router"
import { Link as RouterLink, useNavigate } from "@tanstack/react-router"
+import { z } from "zod"
import { TeamsService, UsersService } from "@/client"
import CustomCard from "@/components/Common/CustomCard"
-import { Button } from "@/components/ui/button"
+import {
+ PaginationItems,
+ PaginationNextTrigger,
+ PaginationPrevTrigger,
+ PaginationRoot,
+} from "@/components/ui/pagination"
import { isLoggedIn } from "@/hooks/useAuth"
-import { useEffect } from "react"
const PER_PAGE = 5
@@ -34,8 +36,7 @@ function getTeamsQueryOptions({
queryFn: () =>
TeamsService.readTeams({
skip: (page - 1) * PER_PAGE,
- // Fetching one extra to determine if there's a next page
- limit: PER_PAGE + 1,
+ limit: PER_PAGE,
orderBy,
order,
}),
@@ -79,8 +80,6 @@ export const Route = createFileRoute("/_layout/teams/all")({
})
function AllTeams() {
- const queryClient = useQueryClient()
- const { page = 1, orderBy, order } = Route.useSearch()
const navigate = useNavigate({ from: Route.fullPath })
const setPage = (page: number) =>
navigate({
@@ -88,23 +87,12 @@ function AllTeams() {
})
const {
- teams: { data },
+ teams: { data, count },
currentUser,
} = Route.useLoaderData()
- const hasNextPage = data.length === PER_PAGE + 1
const teams = data.slice(0, PER_PAGE)
- const hasPreviousPage = page > 1
-
- useEffect(() => {
- if (hasNextPage) {
- queryClient.prefetchQuery(
- getTeamsQueryOptions({ page: page + 1, orderBy, order }),
- )
- }
- }, [page, queryClient, hasNextPage, orderBy, order])
-
return (
@@ -119,6 +107,7 @@ function AllTeams() {
size={{ base: "sm", md: "md" }}
variant="outline"
data-testid="teams-table"
+ interactive
>
@@ -131,7 +120,6 @@ function AllTeams() {
{teams.map((team) => (
- {/* TODO: Add hover */}
{team.name}
{team.is_personal_team ? (
Personal
@@ -147,26 +135,19 @@ function AllTeams() {
))}
- {(hasPreviousPage || hasNextPage) && (
-
+ setPage(page)}
>
-
- Page {page}
-
-
- )}
+
+
+
+
+
+
+
)