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} - - - )} + + + + + + + )