From f0c67f90f80e2597dc95665779baf327c348ac4f Mon Sep 17 00:00:00 2001 From: aliang <1098486429@qq.com> Date: Fri, 2 Feb 2024 15:44:29 +0800 Subject: [PATCH] feat(ui): implement pagination of members table (#1326) * feat(ui): provider pagination of members table * fix(ui): default pagesize * [autofix.ci] apply automated fixes * fix(ui): use shadcn pagination * [autofix.ci] apply automated fixes * fix(ui): handle the data being returned * fix(ui): refactoring operation colum style * fix(ui): invitation table pagination * [autofix.ci] apply automated fixes * feat(ui): integrate @urql/exchange-graphcache * [autofix.ci] apply automated fixes * fix(ui): format * Apply suggestions from code review --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Meng Zhang --- .../team/components/invitation-table.tsx | 158 +++++++++++++++--- .../team/components/user-table.tsx | 83 +++++++-- ee/tabby-ui/components/simple-pagination.tsx | 33 ---- ee/tabby-ui/components/ui/icons.tsx | 18 ++ ee/tabby-ui/components/ui/pagination.tsx | 128 ++++++++++++++ ee/tabby-ui/lib/tabby/gql.ts | 45 ++++- ee/tabby-ui/package.json | 2 + ee/tabby-ui/yarn.lock | 17 +- 8 files changed, 411 insertions(+), 73 deletions(-) delete mode 100644 ee/tabby-ui/components/simple-pagination.tsx create mode 100644 ee/tabby-ui/components/ui/pagination.tsx diff --git a/ee/tabby-ui/app/(dashboard)/team/components/invitation-table.tsx b/ee/tabby-ui/app/(dashboard)/team/components/invitation-table.tsx index bd28acc0f6d4..42cebff09cfd 100644 --- a/ee/tabby-ui/app/(dashboard)/team/components/invitation-table.tsx +++ b/ee/tabby-ui/app/(dashboard)/team/components/invitation-table.tsx @@ -2,12 +2,24 @@ import React, { useEffect, useState } from 'react' import moment from 'moment' -import { useQuery } from 'urql' +import { toast } from 'sonner' +import { useClient, useQuery } from 'urql' import { graphql } from '@/lib/gql/generates' -import { QueryVariables, useMutation } from '@/lib/tabby/gql' +import { + InvitationEdge, + ListInvitationsQueryVariables +} from '@/lib/gql/generates/graphql' +import { useMutation } from '@/lib/tabby/gql' import { Button } from '@/components/ui/button' import { IconTrash } from '@/components/ui/icons' +import { + Pagination, + PaginationContent, + PaginationItem, + PaginationNext, + PaginationPrevious +} from '@/components/ui/pagination' import { Table, TableBody, @@ -20,7 +32,7 @@ import { CopyButton } from '@/components/copy-button' import CreateInvitationForm from './create-invitation-form' -const listInvitations = graphql(/* GraphQL */ ` +export const listInvitations = graphql(/* GraphQL */ ` query ListInvitations( $after: String $before: String @@ -58,29 +70,118 @@ const deleteInvitationMutation = graphql(/* GraphQL */ ` } `) +const PAGE_SIZE = 20 export default function InvitationTable() { - const [queryVariables, setQueryVariables] = - React.useState>() - const [{ data }, reexecuteQuery] = useQuery({ + const client = useClient() + const [{ data, fetching }] = useQuery({ query: listInvitations, - variables: queryVariables + variables: { first: PAGE_SIZE } }) - const invitations = data?.invitationsNext?.edges + // if a new invitation was created, fetching all records and navigating to the last page + const [fetchingLastPage, setFetchingLastPage] = React.useState(false) + + const [currentPage, setCurrentPage] = React.useState(1) + const edges = data?.invitationsNext?.edges + const pageInfo = data?.invitationsNext?.pageInfo + const pageNum = Math.ceil((edges?.length || 0) / PAGE_SIZE) + + const currentPageInvits = React.useMemo(() => { + return edges?.slice?.( + (currentPage - 1) * PAGE_SIZE, + currentPage * PAGE_SIZE + ) + }, [currentPage, edges]) + + const hasNextPage = pageInfo?.hasNextPage || currentPage < pageNum + const hasPrevPage = currentPage > 1 + + const fetchInvitations = (variables: ListInvitationsQueryVariables) => { + return client.query(listInvitations, variables).toPromise() + } + + const fetchInvitationsSequentially = async ( + cursor?: string + ): Promise => { + const res = await fetchInvitations({ first: PAGE_SIZE, after: cursor }) + let count = res?.data?.invitationsNext?.edges?.length || 0 + const _pageInfo = res?.data?.invitationsNext?.pageInfo + if (_pageInfo?.hasNextPage && _pageInfo?.endCursor) { + // cacheExchange will merge the edges + count = await fetchInvitationsSequentially(_pageInfo.endCursor) + } + return count + } + + const fetchAllRecords = async () => { + try { + setFetchingLastPage(true) + const count = fetchInvitationsSequentially( + pageInfo?.endCursor ?? undefined + ) + return count + } catch (e) { + return 0 + } finally { + setFetchingLastPage(false) + } + } + const [origin, setOrigin] = useState('') useEffect(() => { setOrigin(new URL(window.location.href).origin) }, []) - const deleteInvitation = useMutation(deleteInvitationMutation, { - onCompleted() { - reexecuteQuery() + const deleteInvitation = useMutation(deleteInvitationMutation) + + const handleInvitationCreated = async () => { + toast.success('Invitation created') + fetchAllRecords().then(count => { + setCurrentPage(getPageNumber(count)) + }) + } + + const handleNavToPrevPage = () => { + if (currentPage <= 1) return + if (fetchingLastPage || fetching) return + setCurrentPage(p => p - 1) + } + + const handleFetchNextPage = () => { + if (!hasNextPage) return + if (fetchingLastPage || fetching) return + + fetchInvitations({ first: PAGE_SIZE, after: pageInfo?.endCursor }).then( + data => { + if (data?.data?.invitationsNext?.edges?.length) { + setCurrentPage(p => p + 1) + } + } + ) + } + + const handleDeleteInvatation = (node: InvitationEdge['node']) => { + deleteInvitation({ id: node.id }).then(res => { + if (res?.error) { + toast.error(res.error.message) + return + } + if (res?.data?.deleteInvitationNext) { + toast.success(`${node.email} deleted`) + } + }) + } + + React.useEffect(() => { + if (pageNum < currentPage && currentPage > 1) { + setCurrentPage(pageNum) } - }) + }, [pageNum, currentPage]) return (
- - {!!invitations?.length && ( + +
+ {!!currentPageInvits?.length && ( Invitee @@ -90,7 +191,7 @@ export default function InvitationTable() { )} - {invitations?.map(x => { + {currentPageInvits?.map(x => { const link = `${origin}/auth/signup?invitationCode=${x.node.code}` return ( @@ -102,7 +203,7 @@ export default function InvitationTable() { @@ -113,9 +214,28 @@ export default function InvitationTable() { })}
-
- reexecuteQuery()} /> -
+ {(hasNextPage || hasPrevPage) && ( + + + + + + + + + + + )}
) } + +function getPageNumber(count?: number) { + return Math.ceil((count || 0) / PAGE_SIZE) +} diff --git a/ee/tabby-ui/app/(dashboard)/team/components/user-table.tsx b/ee/tabby-ui/app/(dashboard)/team/components/user-table.tsx index 45335f2480ce..4fdf24c5958e 100644 --- a/ee/tabby-ui/app/(dashboard)/team/components/user-table.tsx +++ b/ee/tabby-ui/app/(dashboard)/team/components/user-table.tsx @@ -6,6 +6,7 @@ import { toast } from 'sonner' import { useQuery } from 'urql' import { graphql } from '@/lib/gql/generates' +import type { ListUsersNextQuery } from '@/lib/gql/generates/graphql' import { QueryVariables, useMutation } from '@/lib/tabby/gql' import type { ArrayElementType } from '@/lib/types' import { Badge } from '@/components/ui/badge' @@ -17,6 +18,13 @@ import { DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { IconMore } from '@/components/ui/icons' +import { + Pagination, + PaginationContent, + PaginationItem, + PaginationNext, + PaginationPrevious +} from '@/components/ui/pagination' import { Table, TableBody, @@ -60,19 +68,34 @@ const updateUserActiveMutation = graphql(/* GraphQL */ ` } `) +const PAGE_SIZE = 20 export default function UsersTable() { - const [queryVariables, setQueryVariables] = - React.useState>() - const [{ data }, reexecuteQuery] = useQuery({ + const [queryVariables, setQueryVariables] = React.useState< + QueryVariables + >({ first: PAGE_SIZE }) + const [{ data, error }, reexecuteQuery] = useQuery({ query: listUsers, variables: queryVariables }) - const users = data?.usersNext?.edges + const [users, setUsers] = React.useState() + + React.useEffect(() => { + const _users = data?.usersNext + if (_users?.edges?.length) { + setUsers(_users) + } + }, [data]) + + React.useEffect(() => { + if (error?.message) { + toast.error(error.message) + } + }, [error]) const updateUserActive = useMutation(updateUserActiveMutation) const onUpdateUserActive = ( - node: ArrayElementType['node'], + node: ArrayElementType['node'], active: boolean ) => { updateUserActive({ id: node.id, active }).then(response => { @@ -89,8 +112,10 @@ export default function UsersTable() { }) } + const pageInfo = users?.pageInfo + return ( - !!users?.length && ( + !!users?.edges?.length && ( <> @@ -103,7 +128,7 @@ export default function UsersTable() { - {users.map(x => ( + {users.edges.map(x => ( {x.node.email} {moment.utc(x.node.createdAt).fromNow()} @@ -121,14 +146,16 @@ export default function UsersTable() { MEMBER )} - + - - {x.node.isAdmin ? null : ( - - )} + +
+ {x.node.isAdmin ? null : ( + + )} +
{x.node.active && ( @@ -154,6 +181,34 @@ export default function UsersTable() { ))}
+ {(pageInfo?.hasNextPage || pageInfo?.hasPreviousPage) && ( + + + + + setQueryVariables({ + last: PAGE_SIZE, + before: pageInfo?.startCursor + }) + } + /> + + + + setQueryVariables({ + first: PAGE_SIZE, + after: pageInfo?.endCursor + }) + } + /> + + + + )} ) ) diff --git a/ee/tabby-ui/components/simple-pagination.tsx b/ee/tabby-ui/components/simple-pagination.tsx deleted file mode 100644 index 71bdb6288c26..000000000000 --- a/ee/tabby-ui/components/simple-pagination.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import React from 'react' - -import { cn } from '@/lib/utils' - -import { Button } from './ui/button' -import { IconChevronRight } from './ui/icons' - -interface SimplePagination extends React.HTMLAttributes { - hasPreviousPage: boolean | undefined - hasNextPage: boolean | undefined - onNext: () => void - onPrev: () => void -} -const SimplePagination: React.FC = ({ - className, - hasPreviousPage, - hasNextPage, - onNext, - onPrev -}) => { - return ( -
- - -
- ) -} - -export { SimplePagination } diff --git a/ee/tabby-ui/components/ui/icons.tsx b/ee/tabby-ui/components/ui/icons.tsx index a387f6c1071e..adab6f9a0c59 100644 --- a/ee/tabby-ui/components/ui/icons.tsx +++ b/ee/tabby-ui/components/ui/icons.tsx @@ -204,6 +204,23 @@ function IconChevronRight({ ) } +function IconChevronLeft({ className, ...props }: React.ComponentProps<'svg'>) { + return ( + + + + ) +} function IconChevronDown({ className, ...props }: React.ComponentProps<'svg'>) { return ( @@ -881,6 +898,7 @@ export { IconNetwork, IconRotate, IconChevronRight, + IconChevronLeft, IconChevronDown, IconFile, IconDirectorySolid, diff --git a/ee/tabby-ui/components/ui/pagination.tsx b/ee/tabby-ui/components/ui/pagination.tsx new file mode 100644 index 000000000000..0c0ca4553e51 --- /dev/null +++ b/ee/tabby-ui/components/ui/pagination.tsx @@ -0,0 +1,128 @@ +import * as React from 'react' + +import { cn } from '@/lib/utils' +import { ButtonProps, buttonVariants } from '@/components/ui/button' + +import { IconChevronLeft, IconChevronRight, IconMore } from './icons' + +const Pagination = ({ className, ...props }: React.ComponentProps<'nav'>) => ( +