From 54274d7af9c3d01db5b71c246a715d06debf37c5 Mon Sep 17 00:00:00 2001 From: Joseph Chalabi <100090645+chalabi2@users.noreply.github.com> Date: Wed, 27 Nov 2024 08:56:34 -0700 Subject: [PATCH] feat: group pagination (#92) Co-authored-by: Felix C. Morency <1102868+fmorency@users.noreply.github.com> --- .../components/__tests__/myGroups.test.tsx | 83 ++++++++++++++++++ components/groups/components/myGroups.tsx | 86 ++++++++++++++++++- hooks/index.tsx | 1 + hooks/useIsMobile.ts | 29 +++++++ 4 files changed, 196 insertions(+), 3 deletions(-) create mode 100644 hooks/useIsMobile.ts diff --git a/components/groups/components/__tests__/myGroups.test.tsx b/components/groups/components/__tests__/myGroups.test.tsx index 30b5c245..2b94b379 100644 --- a/components/groups/components/__tests__/myGroups.test.tsx +++ b/components/groups/components/__tests__/myGroups.test.tsx @@ -31,6 +31,11 @@ mock.module('@/hooks/useQueries', () => ({ }), })); +mock.module('@/hooks/useIsMobile', () => ({ + __esModule: true, + default: jest.fn().mockReturnValue(false), +})); + const mockProps = { groups: { groups: [ @@ -48,6 +53,23 @@ const mockProps = { isLoading: false, }; +const mockPropsWithManyGroups = { + groups: { + groups: Array(12) + .fill(null) + .map((_, index) => ({ + id: `${index + 1}`, + ipfsMetadata: { title: `Group ${index + 1}` }, + policies: [{ address: `policy${index + 1}`, decision_policy: { threshold: '1' } }], + admin: `admin${index + 1}`, + members: [{ member: { address: `member${index + 1}` } }], + total_weight: '1', + })), + }, + proposals: {}, + isLoading: false, +}; + describe('YourGroups Component', () => { afterEach(() => { mock.restore(); @@ -86,4 +108,65 @@ describe('YourGroups Component', () => { { shallow: true } ); }); + + describe('Pagination', () => { + test('renders pagination controls when there are more items than page size', () => { + renderWithChainProvider(); + + expect(screen.getByRole('navigation', { name: /pagination/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /next page/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /previous page/i })).toBeInTheDocument(); + }); + + test('pagination controls navigate between pages correctly', () => { + renderWithChainProvider(); + + // Should start with page 1 + expect(screen.getByRole('button', { name: 'Page 1', current: 'page' })).toBeInTheDocument(); + + // Click next page + fireEvent.click(screen.getByRole('button', { name: /next page/i })); + expect(screen.getByRole('button', { name: 'Page 2', current: 'page' })).toBeInTheDocument(); + + // Click previous page + fireEvent.click(screen.getByRole('button', { name: /previous page/i })); + expect(screen.getByRole('button', { name: 'Page 1', current: 'page' })).toBeInTheDocument(); + }); + + test('previous button is disabled on first page', () => { + renderWithChainProvider(); + + const prevButton = screen.getByRole('button', { name: /previous page/i }); + expect(prevButton).toBeDisabled(); + }); + + test('next button is disabled on last page', () => { + renderWithChainProvider(); + + // Navigate to last page + const totalPages = Math.ceil(mockPropsWithManyGroups.groups.groups.length / 8); + for (let i = 1; i < totalPages; i++) { + fireEvent.click(screen.getByRole('button', { name: /next page/i })); + } + + const nextButton = screen.getByRole('button', { name: /next page/i }); + expect(nextButton).toBeDisabled(); + }); + + test('direct page selection works correctly', () => { + renderWithChainProvider(); + + // Click page 2 button + fireEvent.click(screen.getByRole('button', { name: 'Page 2' })); + expect(screen.getByRole('button', { name: 'Page 2', current: 'page' })).toBeInTheDocument(); + }); + + test('shows correct number of items per page', () => { + renderWithChainProvider(); + + // On desktop (non-mobile), should show 8 items per page + const groupRows = screen.getAllByRole('button', { name: /Select Group \d+ group/i }); + expect(groupRows).toHaveLength(8); + }); + }); }); diff --git a/components/groups/components/myGroups.tsx b/components/groups/components/myGroups.tsx index 93cd7a3c..6c9ae5da 100644 --- a/components/groups/components/myGroups.tsx +++ b/components/groups/components/myGroups.tsx @@ -18,6 +18,7 @@ import { PiInfo } from 'react-icons/pi'; import { GroupInfo } from '../modals/groupInfo'; import { MemberManagementModal } from '../modals/memberManagementModal'; import { useChain } from '@cosmos-kit/react'; +import useIsMobile from '@/hooks/useIsMobile'; export function YourGroups({ groups, @@ -31,6 +32,11 @@ export function YourGroups({ refetch: () => void; }) { const [searchTerm, setSearchTerm] = useState(''); + const [currentPage, setCurrentPage] = useState(1); + const isMobile = useIsMobile(); + + const pageSize = isMobile ? 6 : 8; + const [selectedGroup, setSelectedGroup] = useState<{ policyAddress: string; name: string; @@ -44,6 +50,12 @@ export function YourGroups({ (group.ipfsMetadata?.title || 'Untitled Group').toLowerCase().includes(searchTerm.toLowerCase()) ); + const totalPages = Math.ceil(filteredGroups.length / pageSize); + const paginatedGroups = filteredGroups.slice( + (currentPage - 1) * pageSize, + currentPage * pageSize + ); + useEffect(() => { // Check if there's a policy address in the URL on component mount const { policyAddress } = router.query; @@ -134,7 +146,7 @@ export function YourGroups({ {isLoading - ? Array(10) + ? Array(8) .fill(0) .map((_, index) => ( @@ -174,12 +186,14 @@ export function YourGroups({ )) - : filteredGroups.map((group, index) => ( + : paginatedGroups.map((group, index) => ( 0 + group.policies && + group.policies.length > 0 && + proposals[group.policies[0].address] ? proposals[group.policies[0].address] : [] } @@ -188,6 +202,70 @@ export function YourGroups({ ))} + {totalPages > 1 && ( +
e.stopPropagation()} + role="navigation" + aria-label="Pagination" + > + + + {[...Array(totalPages)].map((_, index) => { + const pageNum = index + 1; + if ( + pageNum === 1 || + pageNum === totalPages || + (pageNum >= currentPage - 1 && pageNum <= currentPage + 1) + ) { + return ( + + ); + } else if (pageNum === currentPage - 2 || pageNum === currentPage + 2) { + return ( + + ); + } + return null; + })} + + +
+ )}
@@ -247,6 +325,8 @@ export function YourGroups({ /> ))} + + {/* Add pagination controls */}
); } diff --git a/hooks/index.tsx b/hooks/index.tsx index a575fa9e..c594bbcb 100644 --- a/hooks/index.tsx +++ b/hooks/index.tsx @@ -5,3 +5,4 @@ export * from './usePoaLcdQueryClient'; export * from './useQueries'; export * from './useTx'; export * from './useContacts'; +export { default as useIsMobile } from './useIsMobile'; diff --git a/hooks/useIsMobile.ts b/hooks/useIsMobile.ts new file mode 100644 index 00000000..be8cbdfd --- /dev/null +++ b/hooks/useIsMobile.ts @@ -0,0 +1,29 @@ +import { useState, useEffect } from 'react'; + +const useIsMobile = (breakpoint: number = 1024) => { + const [isMobile, setIsMobile] = useState(false); + + useEffect(() => { + // Only run on client side + if (typeof window === 'undefined') { + return; + } + + const checkIsMobile = () => { + setIsMobile(window.innerWidth <= breakpoint); + }; + + // Initial check + checkIsMobile(); + + // Add event listener + window.addEventListener('resize', checkIsMobile); + + // Cleanup + return () => window.removeEventListener('resize', checkIsMobile); + }, [breakpoint]); + + return isMobile; +}; + +export default useIsMobile;