diff --git a/components/buttons/Button.tsx b/components/buttons/Button.tsx index e19093238ebc..40d5a85eeda5 100644 --- a/components/buttons/Button.tsx +++ b/components/buttons/Button.tsx @@ -76,13 +76,13 @@ export default function Button({ data-testid='Button-main' > {icon && iconPosition === ButtonIconPosition.LEFT && ( - + {icon} )} {text} {icon && iconPosition === ButtonIconPosition.RIGHT && ( - + {icon} )} @@ -98,9 +98,9 @@ export default function Button({ className={buttonSize === ButtonSize.SMALL ? smallButtonClasses : classNames} data-testid='Button-link' > - {icon && iconPosition === ButtonIconPosition.LEFT && {icon}} + {icon && iconPosition === ButtonIconPosition.LEFT && {icon}} {text} - {icon && iconPosition === ButtonIconPosition.RIGHT && {icon}} + {icon && iconPosition === ButtonIconPosition.RIGHT && {icon}} ); } diff --git a/components/helpers/usePagination.ts b/components/helpers/usePagination.ts new file mode 100644 index 000000000000..5f3501da13a0 --- /dev/null +++ b/components/helpers/usePagination.ts @@ -0,0 +1,52 @@ +import { useCallback, useMemo, useState } from 'react'; + +/** + * @description Custom hook for managing pagination logic + * @example const { currentPage, setCurrentPage, currentItems, maxPage } = usePagination(items, 10, { currentPage: 2 }); + * @param {T[]} items - Array of items to paginate + * @param {number} itemsPerPage - Number of items per page + * @param {object} [options] - Pagination options + * @param {number} [options.currentPage] - Optional current page (controlled) + * @param {(page: number) => void} [options.onPageChange] - Called when setCurrentPage is invoked in controlled mode + * @returns {object} + * @returns {number} currentPage - Current page number + * @returns {function} setCurrentPage - Function to update the current page + * @returns {T[]} currentItems - Items for the current page + * @returns {number} maxPage - Total number of pages + */ +export function usePagination( + items: T[], + itemsPerPage: number, + options: { currentPage?: number; onPageChange?: (page: number) => void } = {} +) { + const [internalPage, setInternalPage] = useState(1); + const { currentPage: controlledPage, onPageChange } = options; + const isControlled = typeof controlledPage === 'number' && !Number.isNaN(controlledPage); + const page = isControlled ? (controlledPage as number) : internalPage; + const maxPage = Math.ceil(items.length / itemsPerPage); + const safePage = maxPage === 0 ? 1 : Math.min(Math.max(page, 1), maxPage); + const setCurrentPage = useCallback( + (nextPage: number) => { + if (isControlled) { + onPageChange?.(nextPage); + + return; + } + setInternalPage(nextPage); + }, + [isControlled, onPageChange] + ); + + const currentItems = useMemo(() => { + const start = (safePage - 1) * itemsPerPage; + + return items.slice(start, start + itemsPerPage); + }, [items, safePage, itemsPerPage]); + + return { + currentPage: safePage, + setCurrentPage, + currentItems, + maxPage + }; +} diff --git a/components/icons/Next.tsx b/components/icons/Next.tsx new file mode 100644 index 000000000000..97e34cd26bc4 --- /dev/null +++ b/components/icons/Next.tsx @@ -0,0 +1,20 @@ +import React from 'react'; + +/* eslint-disable max-len */ +/** + * @description Icons for Next button + */ +export default function IconNext() { + return ( + + + + ); +} diff --git a/components/icons/Previous.tsx b/components/icons/Previous.tsx new file mode 100644 index 000000000000..3bf10d5e3b84 --- /dev/null +++ b/components/icons/Previous.tsx @@ -0,0 +1,20 @@ +import React from 'react'; + +/* eslint-disable max-len */ +/** + * @description Icons for Previous button in pagination + */ +export default function IconPrevious() { + return ( + + + + ); +} diff --git a/components/navigation/Filter.tsx b/components/navigation/Filter.tsx index 066de17adf76..355fdbc5fd9f 100644 --- a/components/navigation/Filter.tsx +++ b/components/navigation/Filter.tsx @@ -1,5 +1,5 @@ import { useRouter } from 'next/router'; -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import Select from '../form/Select'; import { applyFilterList, type DataObject, type Filter as FilterQuery, onFilterApply } from '../helpers/applyFilter'; @@ -10,7 +10,7 @@ interface Check { interface FilterProps { data: T[]; - onFilter: (data: T[], query: FilterQuery) => void; + onFilter?: (data: T[], query: FilterQuery) => void; checks: Check[]; className?: string; } @@ -32,6 +32,7 @@ export default function Filter({ const route = useRouter(); const [filters, setFilters] = useState>({}); const [routeQuery, setQuery] = useState>({}); + const lastFilterKeyRef = useRef(null); useEffect(() => { setQuery(route.query as Record); @@ -39,7 +40,16 @@ export default function Filter({ }, [route, checks, data]); useEffect(() => { - onFilterApply(data, onFilter, routeQuery); + if (!onFilter) return; + const filterableQuery = Object.fromEntries(Object.entries(routeQuery).filter(([key]) => key !== 'page')); + const filterKey = Object.entries(filterableQuery) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, value]) => `${key}:${value}`) + .join('|'); + + if (filterKey === lastFilterKeyRef.current) return; + lastFilterKeyRef.current = filterKey; + onFilterApply(data, onFilter, filterableQuery); }, [routeQuery, data, onFilter]); return checks.map((check) => { diff --git a/components/pagination/Pagination.tsx b/components/pagination/Pagination.tsx new file mode 100644 index 000000000000..71706e6c58e2 --- /dev/null +++ b/components/pagination/Pagination.tsx @@ -0,0 +1,118 @@ +import React from 'react'; + +import { ButtonIconPosition } from '@/types/components/buttons/ButtonPropsType'; + +import Button from '../buttons/Button'; +import IconNext from '../icons/Next'; +import IconPrevious from '../icons/Previous'; +import PaginationItem from './PaginationItem'; + +export interface PaginationProps { + // eslint-disable-next-line prettier/prettier + + /** Total number of pages */ + totalPages: number; + + /** Current active page */ + currentPage: number; + + /** Function to handle page changes */ + onPageChange: (page: number) => void; +} + +/** + * This is the Pagination component. It displays a list of page numbers that can be clicked to navigate. + */ +export default function Pagination({ totalPages, currentPage, onPageChange }: PaginationProps) { + const handlePageChange = (page: number) => { + if (page >= 1 && page <= totalPages) { + onPageChange(page); + } + }; + + /** + * @returns number of pages shows in Pagination. + */ + const getPageNumber = (): (number | 'start-ellipsis' | 'end-ellipsis')[] => { + if (totalPages <= 6) { + return Array.from({ length: Math.max(0, totalPages) }, (_, i) => i + 1); + } + + const pages: (number | 'start-ellipsis' | 'end-ellipsis')[] = [1]; + + const left = Math.max(2, currentPage - 1); + const right = Math.min(totalPages - 1, currentPage + 1); + + if (left > 2) pages.push('start-ellipsis'); + for (let i = left; i <= right; i++) pages.push(i); + if (right < totalPages - 1) pages.push('end-ellipsis'); + + pages.push(totalPages); + + return pages; + }; + + return ( + + ); +} diff --git a/components/pagination/PaginationItem.tsx b/components/pagination/PaginationItem.tsx new file mode 100644 index 000000000000..05140d05f956 --- /dev/null +++ b/components/pagination/PaginationItem.tsx @@ -0,0 +1,38 @@ +import React from 'react'; + +export interface PaginationItemProps { + // eslint-disable-next-line prettier/prettier + + /** The page number to display */ + pageNumber: number; + + /** Whether this page is currently active */ + isActive: boolean; + + /** Function to handle page change */ + onPageChange: (page: number) => void; +} + +/** + * This is the PaginationItem component. It displays a single page number that can be clicked. + */ +export default function PaginationItem({ + pageNumber, + isActive, + onPageChange, + ...buttonProps +}: PaginationItemProps & React.ButtonHTMLAttributes) { + return ( + + ); +} diff --git a/pages/blog/index.tsx b/pages/blog/index.tsx index f1d5dce33b60..8649984ab2bc 100644 --- a/pages/blog/index.tsx +++ b/pages/blog/index.tsx @@ -1,12 +1,14 @@ import { useRouter } from 'next/router'; -import React, { useCallback, useContext, useEffect, useState } from 'react'; +import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react'; -import type { Filter as FilterType } from '@/components/helpers/applyFilter'; +import { type Filter as FilterType, onFilterApply } from '@/components/helpers/applyFilter'; +import { usePagination } from '@/components/helpers/usePagination'; import Empty from '@/components/illustrations/Empty'; import GenericLayout from '@/components/layout/GenericLayout'; import Loader from '@/components/Loader'; import BlogPostItem from '@/components/navigation/BlogPostItem'; import Filter from '@/components/navigation/Filter'; +import Pagination from '@/components/pagination/Pagination'; import Heading from '@/components/typography/Heading'; import Paragraph from '@/components/typography/Paragraph'; import TextLink from '@/components/typography/TextLink'; @@ -22,23 +24,104 @@ export default function BlogIndexPage() { const router = useRouter(); const { navItems } = useContext(BlogContext); - const [posts, setPosts] = useState( - navItems - ? navItems.sort((i1: IBlogPost, i2: IBlogPost) => { - const i1Date = new Date(i1.date); - const i2Date = new Date(i2.date); + const sortedPosts = useMemo(() => { + if (!navItems) return []; - if (i1.featured && !i2.featured) return -1; - if (!i1.featured && i2.featured) return 1; + return [...navItems].sort((i1: IBlogPost, i2: IBlogPost) => { + const i1Date = new Date(i1.date); + const i2Date = new Date(i2.date); - return i2Date.getTime() - i1Date.getTime(); - }) - : [] + if (i1.featured && !i2.featured) return -1; + if (!i1.featured && i2.featured) return 1; + + return i2Date.getTime() - i1Date.getTime(); + }); + }, [navItems]); + + const postsPerPage = 9; + const filters = useMemo(() => { + const entries = Object.entries(router.query).filter(([key]) => key !== 'page'); + const result: FilterType = {}; + + entries.forEach(([key, value]) => { + if (Array.isArray(value)) { + const [first] = value; + + if (first) result[key] = first; + } else if (typeof value === 'string' && value.length > 0) { + result[key] = value; + } + }); + + return result; + }, [router.query]); + + const filteredPosts = useMemo(() => { + let result = sortedPosts; + + onFilterApply( + sortedPosts, + (next) => { + result = next; + }, + filters + ); + + return result; + }, [sortedPosts, filters]); + const totalPages = Math.ceil(filteredPosts.length / postsPerPage); + const pageParam = Array.isArray(router.query.page) ? router.query.page[0] : router.query.page; + const pageFromQuery = pageParam ? parseInt(pageParam, 10) : 1; + const normalizedPage = Number.isNaN(pageFromQuery) ? 1 : pageFromQuery; + const queryPage = totalPages > 0 && (normalizedPage < 1 || normalizedPage > totalPages) ? 1 : normalizedPage; + const { currentPage, currentItems, maxPage } = usePagination(filteredPosts, postsPerPage, { currentPage: queryPage }); + + const handlePageChange = useCallback( + (page: number) => { + if (page === currentPage) return; + + const currentFilters = { ...router.query }; + + if (page <= 1) { + delete currentFilters.page; + } else { + currentFilters.page = page.toString(); + } + + router.push( + { + pathname: router.pathname, + query: currentFilters + }, + undefined, + { shallow: true, scroll: true } + ); + }, + [router, currentPage] ); - const [isClient, setIsClient] = useState(false); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const onFilter = useCallback((data: IBlogPost[], _query: FilterType) => setPosts(data), []); + useEffect(() => { + if (!router.isReady) return; + if (!pageParam) return; + + if (Number.isNaN(pageFromQuery) || pageFromQuery < 1 || (totalPages > 0 && pageFromQuery > totalPages)) { + const nextQuery = { ...router.query }; + + delete nextQuery.page; + + router.replace( + { + pathname: router.pathname, + query: nextQuery + }, + undefined, + { shallow: true, scroll: false } + ); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [router.isReady, router.query, pageParam, pageFromQuery, totalPages]); + + const [isClient, setIsClient] = useState(false); const toFilter = [ { name: 'type' @@ -51,12 +134,14 @@ export default function BlogIndexPage() { name: 'tags' } ]; + const clearFilters = () => { router.push(`${router.pathname}`, undefined, { shallow: true }); }; - const showClearFilters = Object.keys(router.query).length > 0; + + const showClearFilters = Object.keys(filters).length > 0; const description = 'Find the latest and greatest stories from our community'; const image = '/img/social/blog.webp'; @@ -106,8 +191,7 @@ export default function BlogIndexPage() {
@@ -122,24 +206,29 @@ export default function BlogIndexPage() { )}
- {Object.keys(posts).length === 0 && ( + {filteredPosts.length === 0 && (

No post matches your filter

)} - {Object.keys(posts).length > 0 && isClient && ( + {filteredPosts.length > 0 && isClient && (
    - {posts.map((post, index) => ( - + {currentItems.map((post) => ( + ))}
)} - {Object.keys(posts).length > 0 && !isClient && ( + {currentItems.length > 0 && !isClient && (
)} + {maxPage > 1 && ( +
+ +
+ )}