Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add pagination feature in blog page #3595

Open
wants to merge 12 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions components/helpers/applyFilter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,10 +133,14 @@ export const onFilterApply = (
onFilter: (result: DataObject[], query: Filter) => void,
query: Filter
): void => {
const nonFilterableKeys = ['page'];
let result = inputData;

if (query && Object.keys(query).length >= 1) {
Object.keys(query).forEach((property) => {
if (nonFilterableKeys.includes(property)) {
return; // Skip non-filterable keys like 'page'
}
const res = result.filter((e) => {
if (!query[property] || e[property] === query[property]) {
return e[property];
Expand Down
30 changes: 30 additions & 0 deletions components/helpers/usePagination.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { useMemo, useState } from 'react';

/**
* @description Custom hook for managing pagination logic
* @example const { currentPage, setCurrentPage, currentItems, maxPage } = usePagination(items, 10);
* @param {T[]} items - Array of items to paginate
* @param {number} itemsPerPage - Number of items per page
* @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<T>(items: T[], itemsPerPage: number) {
Copy link
Member

@akshatnema akshatnema Jan 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is T here?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It takes a prop of items(all items) and returns the only item that has to be displayed on the current page

const [currentPage, setCurrentPage] = useState(1);
const maxPage = Math.ceil(items.length / itemsPerPage);

const currentItems = useMemo(() => {
const start = (currentPage - 1) * itemsPerPage;

return items.slice(start, start + itemsPerPage);
}, [items, currentPage, itemsPerPage]);

return {
currentPage,
setCurrentPage,
currentItems,
maxPage
};
}
20 changes: 20 additions & 0 deletions components/icons/Next.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import React from 'react';

/* eslint-disable max-len */
/**
* @description Icons for Next button
*/
export default function IconNext() {
return (
<svg
width='20'
height='20'
viewBox='0 0 24 24'
fill='none'
xmlns='http://www.w3.org/2000/svg'
className='stroke-current'
>
<path d='M9 6L15 12L9 18' strokeWidth='2' strokeLinecap='round' strokeLinejoin='round' />
</svg>
);
}
20 changes: 20 additions & 0 deletions components/icons/Previous.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import React from 'react';

/* eslint-disable max-len */
/**
* @description Icons for Previous button in pagination
*/
export default function IconPrevios() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Fix typo in component name: IconPreviosIconPrevious

The component name has a typo that should be corrected for clarity and consistency.

-export default function IconPrevios() {
+export default function IconPrevious() {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export default function IconPrevios() {
export default function IconPrevious() {

return (
<svg
width='20'
height='20'
viewBox='0 0 24 24'
fill='none'
xmlns='http://www.w3.org/2000/svg'
className='stroke-current'
>
<path d='M15 18L9 12L15 6' strokeWidth='2' strokeLinecap='round' strokeLinejoin='round' />
</svg>
);
}
108 changes: 108 additions & 0 deletions components/pagination/Pagination.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import React from 'react';

import { ButtonIconPosition } from '@/types/components/buttons/ButtonPropsType';

import Button from '../buttons/Button';
import IconNext from '../icons/Next';
import IconPrevios 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 getPageNumbers = (): (number | string)[] => {
if (totalPages <= 7) {
return Array.from({ length: totalPages }, (_, i) => i + 1);
}

const pages: (number | string)[] = [1];

if (currentPage > 3) {
pages.push('ellipsis1');
}

const start = Math.max(2, currentPage - 1);
const end = Math.min(totalPages - 1, currentPage + 1);

for (let i = start; i <= end; i++) {
pages.push(i);
}

if (currentPage < totalPages - 2) {
pages.push('ellipsis2');
}

pages.push(totalPages);

return pages;
};

return (
<div className='font-inter flex items-center justify-center gap-8'>
{/* Previous button */}
<Button
onClick={() => handlePageChange(currentPage - 1)}
className={`font-normal flex h-[34px] items-center justify-center rounded bg-white px-3 py-[7px] text-sm leading-[17px] tracking-[-0.01em] ${
currentPage === 1 ? 'cursor-not-allowed text-gray-300' : 'text-[#141717] hover:bg-gray-50'
}`}
text='Previous'
icon={<IconPrevios />}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Update import to use corrected icon name

Once the IconPrevios component is renamed to IconPrevious, update this import reference.

-icon={<IconPrevios />}
+icon={<IconPrevious />}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
icon={<IconPrevios />}
icon={<IconPrevious />}

iconPosition={ButtonIconPosition.LEFT}
/>

{/* Page numbers */}
<div className='flex gap-2'>
{getPageNumbers().map((page) =>
typeof page === 'number' ? (
<PaginationItem
key={page}
pageNumber={page}
isActive={page === currentPage}
onPageChange={handlePageChange}
/>
) : (
<span
key={page}
className='font-inter flex size-10 items-center justify-center text-sm font-semibold text-[#6B6B6B]'
>
...
</span>
)
)}
</div>

{/* Next button */}
<Button
onClick={() => handlePageChange(currentPage + 1)}
className={`font-normal flex h-[34px] items-center justify-center rounded bg-white px-3 py-[7px] text-sm leading-[17px] tracking-[-0.01em] ${
currentPage === totalPages ? 'cursor-not-allowed text-gray-300' : 'text-[#141717] hover:bg-gray-50'
}`}
text='Next'
icon={<IconNext />}
/>
</div>
);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Enhance accessibility and extract styles

The pagination component needs accessibility improvements and style cleanup:

  1. Add ARIA labels for navigation
  2. Extract complex conditional styles into utility classes or CSS modules
  3. Add keyboard navigation support
 return (
-  <div className='font-inter flex items-center justify-center gap-8'>
+  <nav
+    role="navigation"
+    aria-label="Pagination"
+    className='font-inter flex items-center justify-center gap-8'
+  >
     {/* Previous button */}
     <Button
       onClick={() => handlePageChange(currentPage - 1)}
+      disabled={currentPage === 1}
       className={`font-normal flex h-[34px] items-center justify-center rounded bg-white px-3 py-[7px] text-sm leading-[17px] tracking-[-0.01em] ${
         currentPage === 1 ? 'cursor-not-allowed text-gray-300' : 'text-[#141717] hover:bg-gray-50'
       }`}
       text='Previous'
       icon={<IconPrevios />}
       iconPosition={ButtonIconPosition.LEFT}
+      aria-label="Go to previous page"
     />

     {/* Page numbers */}
-    <div className='flex gap-2'>
+    <div className='flex gap-2' role="list">
       {getPageNumbers().map((page) =>
         typeof page === 'number' ? (
           <PaginationItem
             key={page}
             pageNumber={page}
             isActive={page === currentPage}
             onPageChange={handlePageChange}
+            aria-label={`Go to page ${page}`}
+            aria-current={page === currentPage ? 'page' : undefined}
           />
         ) : (
           <span
             key={page}
             className='font-inter flex size-10 items-center justify-center text-sm font-semibold text-[#6B6B6B]'
+            aria-hidden="true"
           >
             ...
           </span>
         )
       )}
     </div>

     {/* Next button */}
     <Button
       onClick={() => handlePageChange(currentPage + 1)}
+      disabled={currentPage === totalPages}
       className={`font-normal flex h-[34px] items-center justify-center rounded bg-white px-3 py-[7px] text-sm leading-[17px] tracking-[-0.01em] ${
         currentPage === totalPages ? 'cursor-not-allowed text-gray-300' : 'text-[#141717] hover:bg-gray-50'
       }`}
       text='Next'
       icon={<IconNext />}
+      aria-label="Go to next page"
     />
-  </div>
+  </nav>
 );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
return (
<div className='font-inter flex items-center justify-center gap-8'>
{/* Previous button */}
<Button
onClick={() => handlePageChange(currentPage - 1)}
className={`font-normal flex h-[34px] items-center justify-center rounded bg-white px-3 py-[7px] text-sm leading-[17px] tracking-[-0.01em] ${
currentPage === 1 ? 'cursor-not-allowed text-gray-300' : 'text-[#141717] hover:bg-gray-50'
}`}
text='Previous'
icon={<IconPrevios />}
iconPosition={ButtonIconPosition.LEFT}
/>
{/* Page numbers */}
<div className='flex gap-2'>
{getPageNumbers().map((page) =>
typeof page === 'number' ? (
<PaginationItem
key={page}
pageNumber={page}
isActive={page === currentPage}
onPageChange={handlePageChange}
/>
) : (
<span
key={page}
className='font-inter flex size-10 items-center justify-center text-sm font-semibold text-[#6B6B6B]'
>
...
</span>
)
)}
</div>
{/* Next button */}
<Button
onClick={() => handlePageChange(currentPage + 1)}
className={`font-normal flex h-[34px] items-center justify-center rounded bg-white px-3 py-[7px] text-sm leading-[17px] tracking-[-0.01em] ${
currentPage === totalPages ? 'cursor-not-allowed text-gray-300' : 'text-[#141717] hover:bg-gray-50'
}`}
text='Next'
icon={<IconNext />}
/>
</div>
);
return (
<nav
role="navigation"
aria-label="Pagination"
className='font-inter flex items-center justify-center gap-8'
>
{/* Previous button */}
<Button
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1}
className={`font-normal flex h-[34px] items-center justify-center rounded bg-white px-3 py-[7px] text-sm leading-[17px] tracking-[-0.01em] ${
currentPage === 1 ? 'cursor-not-allowed text-gray-300' : 'text-[#141717] hover:bg-gray-50'
}`}
text='Previous'
icon={<IconPrevios />}
iconPosition={ButtonIconPosition.LEFT}
aria-label="Go to previous page"
/>
{/* Page numbers */}
<div className='flex gap-2' role="list">
{getPageNumbers().map((page) =>
typeof page === 'number' ? (
<PaginationItem
key={page}
pageNumber={page}
isActive={page === currentPage}
onPageChange={handlePageChange}
aria-label={`Go to page ${page}`}
aria-current={page === currentPage ? 'page' : undefined}
/>
) : (
<span
key={page}
className='font-inter flex size-10 items-center justify-center text-sm font-semibold text-[#6B6B6B]'
aria-hidden="true"
>
...
</span>
)
)}
</div>
{/* Next button */}
<Button
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage === totalPages}
className={`font-normal flex h-[34px] items-center justify-center rounded bg-white px-3 py-[7px] text-sm leading-[17px] tracking-[-0.01em] ${
currentPage === totalPages ? 'cursor-not-allowed text-gray-300' : 'text-[#141717] hover:bg-gray-50'
}`}
text='Next'
icon={<IconNext />}
aria-label="Go to next page"
/>
</nav>
);

}
32 changes: 32 additions & 0 deletions components/pagination/PaginationItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
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 }: PaginationItemProps) {
return (
<button
onClick={() => onPageChange(pageNumber)}
className={`font-inter font-normal relative flex size-10 items-center
justify-center rounded-full text-sm leading-[26px]
${isActive ? 'bg-[#6200EE] text-white' : 'bg-transparent text-[#141717] hover:bg-gray-50'}
`}
aria-current={isActive ? 'page' : undefined}
>
{pageNumber}
</button>
);
}
43 changes: 41 additions & 2 deletions pages/blog/index.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { useRouter } from 'next/router';
import React, { useContext, useEffect, useState } from 'react';

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';
Expand Down Expand Up @@ -34,6 +36,38 @@ export default function BlogIndexPage() {
})
: []
);

const postsPerPage = 9;
const { currentPage, setCurrentPage, currentItems, maxPage } = usePagination(posts, postsPerPage);

const handlePageChange = (page: number) => {
setCurrentPage(page);

const currentFilters = { ...router.query, page: page.toString() };

router.push(
{
pathname: router.pathname,
query: currentFilters
},
undefined,
{ shallow: true }
);
};

useEffect(() => {
const pageFromQuery = parseInt(router.query.page as string, 10);

if (!Number.isNaN(pageFromQuery) && maxPage > 0) {
if (pageFromQuery >= 1 && pageFromQuery <= maxPage && pageFromQuery !== currentPage) {
setCurrentPage(pageFromQuery);
} else if (pageFromQuery < 1 || pageFromQuery > maxPage) {
// Only reset to page 1 if the page number is actually invalid
handlePageChange(1);
}
}
}, [router.query.page, maxPage, currentPage]);

const [isClient, setIsClient] = useState(false);

const onFilter = (data: IBlogPost[]) => setPosts(data);
Expand Down Expand Up @@ -122,16 +156,21 @@ export default function BlogIndexPage() {
)}
{Object.keys(posts).length > 0 && isClient && (
<ul className='mx-auto mt-12 grid max-w-lg gap-5 lg:max-w-none lg:grid-cols-3'>
{posts.map((post, index) => (
{currentItems.map((post, index) => (
<BlogPostItem key={index} post={post} />
))}
</ul>
)}
{Object.keys(posts).length > 0 && !isClient && (
{Object.keys(currentItems).length > 0 && !isClient && (
<div className='h-screen w-full'>
<Loader loaderText='Loading Blogs' className='mx-auto my-60' pulsating />
</div>
)}
{maxPage > 1 && (
<div className='mt-8 w-full'>
<Pagination totalPages={maxPage} currentPage={currentPage} onPageChange={handlePageChange} />
</div>
)}
</div>
</div>
</div>
Expand Down
Loading