diff --git a/src/components/DaoDashboard/Activities/ProposalsHome.tsx b/src/components/DaoDashboard/Activities/ProposalsHome.tsx index 15093a701..f1095fdad 100644 --- a/src/components/DaoDashboard/Activities/ProposalsHome.tsx +++ b/src/components/DaoDashboard/Activities/ProposalsHome.tsx @@ -1,11 +1,12 @@ import { Box, Flex, Icon, Show, Button } from '@chakra-ui/react'; import { CaretDown, Funnel } from '@phosphor-icons/react'; -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useState, Dispatch, SetStateAction } from 'react'; import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; import { DAO_ROUTES } from '../../../constants/routes'; import { useProposalsSortedAndFiltered } from '../../../hooks/DAO/proposal/useProposals'; import { useCanUserCreateProposal } from '../../../hooks/utils/useCanUserSubmitProposal'; +import { usePagination } from '../../../hooks/utils/usePagination'; import { useFractal } from '../../../providers/App/AppProvider'; import { useNetworkConfigStore } from '../../../providers/NetworkConfig/useNetworkConfigStore'; import { useDaoInfoStore } from '../../../store/daoInfo/useDaoInfoStore'; @@ -21,6 +22,7 @@ import { CreateProposalMenu } from '../../ui/menus/CreateProposalMenu'; import { OptionMenu } from '../../ui/menus/OptionMenu'; import { ModalType } from '../../ui/modals/ModalProvider'; import { useDecentModal } from '../../ui/modals/useDecentModal'; +import { PaginationControls } from '../../ui/utils/PaginationControls'; import { Sort } from '../../ui/utils/Sort'; import { ActivityFreeze } from './ActivityFreeze'; @@ -36,6 +38,17 @@ export function ProposalsHome() { const { proposals, getProposalsTotal } = useProposalsSortedAndFiltered({ sortBy, filters }); + const { currentPage, setCurrentPage, pageSize, setPageSize, totalPages, getPaginatedItems } = + usePagination({ + totalItems: proposals.length, + }); + + // Calculate paginated proposals + const paginatedProposals = useMemo( + () => getPaginatedItems(proposals), + [proposals, getPaginatedItems], + ); + const { governance, guardContracts } = useFractal(); const { safe } = useDaoInfoStore(); @@ -81,7 +94,6 @@ export function ProposalsHome() { FractalProposalState.ACTIVE, FractalProposalState.EXECUTABLE, FractalProposalState.EXECUTED, - FractalProposalState.REJECTED, ]; @@ -91,7 +103,6 @@ export function ProposalsHome() { FractalProposalState.TIMELOCKED, FractalProposalState.EXECUTABLE, FractalProposalState.EXECUTED, - FractalProposalState.REJECTED, FractalProposalState.EXPIRED, ]; @@ -129,6 +140,7 @@ export function ProposalsHome() { return [...prevState, filter]; } }); + setCurrentPage(1); }; const filterOptions = allOptions.map(state => ({ @@ -138,12 +150,35 @@ export function ProposalsHome() { isSelected: filters.includes(state), })); + const handleSortChange: Dispatch> = value => { + if (typeof value === 'function') { + setSortBy(prev => { + const newValue = value(prev); + setCurrentPage(1); + return newValue; + }); + } else { + setSortBy(value); + setCurrentPage(1); + } + }; + + const handleSelectAll = () => { + setFilters(allOptions); + setCurrentPage(1); + }; + + const handleClearFilters = () => { + setFilters([]); + setCurrentPage(1); + }; + const filterTitle = filters.length === 1 ? t(filters[0]) : filters.length === allOptions.length ? t('filterProposalsAllSelected') - : filters.length === 0 // No filters selected means no filtering applied + : filters.length === 0 ? t('filterProposalsNoneSelected') : t('filterProposalsNSelected', { count: filters.length }); @@ -234,7 +269,7 @@ export function ProposalsHome() { variant="tertiary" size="sm" mt="0.5rem" - onClick={() => setFilters(allOptions)} + onClick={handleSelectAll} > {t('selectAll', { ns: 'common' })} @@ -242,7 +277,7 @@ export function ProposalsHome() { variant="tertiary" size="sm" mt="0.5rem" - onClick={() => setFilters([])} + onClick={handleClearFilters} > {t('clear', { ns: 'common' })} @@ -252,7 +287,7 @@ export function ProposalsHome() { @@ -277,7 +312,23 @@ export function ProposalsHome() { - + + + {/* PAGINATION CONTROLS */} + {proposals.length > 0 && ( + + + + )} ); diff --git a/src/components/Proposals/ProposalsList.tsx b/src/components/Proposals/ProposalsList.tsx index 3dfe4ac4a..c172b1a7e 100644 --- a/src/components/Proposals/ProposalsList.tsx +++ b/src/components/Proposals/ProposalsList.tsx @@ -6,7 +6,11 @@ import NoDataCard from '../ui/containers/NoDataCard'; import { InfoBoxLoader } from '../ui/loaders/InfoBoxLoader'; import ProposalCard from './ProposalCard/ProposalCard'; -export function ProposalsList({ proposals }: { proposals: FractalProposal[] }) { +interface ProposalsListProps { + proposals: FractalProposal[]; +} + +export function ProposalsList({ proposals }: ProposalsListProps) { const { governance: { type, loadingProposals, allProposalsLoaded }, } = useFractal(); @@ -22,15 +26,15 @@ export function ProposalsList({ proposals }: { proposals: FractalProposal[] }) { ) : proposals.length > 0 ? ( - [ - ...proposals.map(proposal => ( + <> + {proposals.map(proposal => ( - )), - !allProposalsLoaded && , - ] + ))} + {!allProposalsLoaded && } + ) : ( void; + isDisabled: boolean; + icon: ComponentType; +} + +function NavButton({ onClick, isDisabled, icon: IconComponent }: NavButtonProps) { + return ( + + ); +} + +interface PaginationControlsProps { + currentPage: number; + totalPages: number; + onPageChange: (page: number) => void; + pageSize: number; + onPageSizeChange: (size: number) => void; +} + +export function PaginationControls({ + currentPage, + totalPages, + onPageChange, + pageSize, + onPageSizeChange, +}: PaginationControlsProps) { + const { t } = useTranslation(['common']); + + return ( + + + + + {pageSize} + + + + + + {PAGE_SIZE_OPTIONS.map(size => ( + onPageSizeChange(size)} + > + {size} + + ))} + + + + + + onPageChange(1)} + isDisabled={currentPage === 1} + icon={CaretDoubleLeft} + /> + onPageChange(currentPage - 1)} + isDisabled={currentPage === 1} + icon={CaretLeft} + /> + + + {t('pageXofY', { current: currentPage, total: totalPages })} + + + onPageChange(currentPage + 1)} + isDisabled={currentPage === totalPages} + icon={CaretRight} + /> + onPageChange(totalPages)} + isDisabled={currentPage === totalPages} + icon={CaretDoubleRight} + /> + + + ); +} diff --git a/src/hooks/utils/usePagination.ts b/src/hooks/utils/usePagination.ts new file mode 100644 index 000000000..b442015dd --- /dev/null +++ b/src/hooks/utils/usePagination.ts @@ -0,0 +1,61 @@ +import { useEffect, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; + +// Only exported for PaginationControls component +export const PAGE_SIZE_OPTIONS = [5, 10, 25, 50, 100]; +const DEFAULT_PAGE_SIZE = 10; + +interface UsePaginationProps { + totalItems: number; +} + +const QUERY_PARAMS = { + PAGE: 'page', + SIZE: 'size', +} as const; + +export function usePagination({ totalItems }: UsePaginationProps) { + const [searchParams, setSearchParams] = useSearchParams(); + + const [currentPage, setCurrentPage] = useState(() => { + const page = searchParams.get(QUERY_PARAMS.PAGE); + return page ? parseInt(page) : 1; + }); + + const [pageSize, setPageSize] = useState(() => { + const size = searchParams.get(QUERY_PARAMS.SIZE); + return size && PAGE_SIZE_OPTIONS.includes(parseInt(size)) ? parseInt(size) : DEFAULT_PAGE_SIZE; + }); + + // Calculate total pages + const totalPages = Math.ceil(totalItems / pageSize); + + // Update URL when state changes + useEffect(() => { + const newParams = new URLSearchParams(searchParams); + newParams.set(QUERY_PARAMS.PAGE, currentPage.toString()); + newParams.set(QUERY_PARAMS.SIZE, pageSize.toString()); + setSearchParams(newParams, { replace: true }); + }, [currentPage, pageSize, searchParams, setSearchParams]); + + // Handle page size changes + const handlePageSizeChange = (newSize: number) => { + setPageSize(newSize); + setCurrentPage(1); + }; + + // Calculate paginated items + const getPaginatedItems = (items: T[]) => { + const startIndex = (currentPage - 1) * pageSize; + return items.slice(startIndex, startIndex + pageSize); + }; + + return { + currentPage, + setCurrentPage, + pageSize, + setPageSize: handlePageSizeChange, + totalPages, + getPaginatedItems, + }; +} diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index f5d6197f8..96d5bed3e 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -118,5 +118,6 @@ "automaticChainSwitchingErrorMessage": "We couldn't automatically switch to the DAO's network. Please try to switch networks in your connected wallet.", "and": "and", "days": "Days", - "owner": "Owner" + "owner": "Owner", + "pageXofY": "Page {{current}} of {{total}}" }