diff --git a/projects/plugins/protect/changelog/add-protect-threats-list-pagination b/projects/plugins/protect/changelog/add-protect-threats-list-pagination
new file mode 100644
index 0000000000000..dc57fd7f5dbf9
--- /dev/null
+++ b/projects/plugins/protect/changelog/add-protect-threats-list-pagination
@@ -0,0 +1,4 @@
+Significance: minor
+Type: added
+
+Adds threats list pagination
diff --git a/projects/plugins/protect/src/js/components/threats-list/free-list.jsx b/projects/plugins/protect/src/js/components/threats-list/free-list.jsx
index b463be6b71feb..af87cac2cb25e 100644
--- a/projects/plugins/protect/src/js/components/threats-list/free-list.jsx
+++ b/projects/plugins/protect/src/js/components/threats-list/free-list.jsx
@@ -5,6 +5,7 @@ import React, { useCallback } from 'react';
import { JETPACK_SCAN_SLUG } from '../../constants';
import useAnalyticsTracks from '../../hooks/use-analytics-tracks';
import FreeAccordion, { FreeAccordionItem } from '../free-accordion';
+import Pagination from './pagination';
import styles from './styles.module.scss';
const ThreatAccordionItem = ( {
@@ -85,38 +86,42 @@ const ThreatAccordionItem = ( {
const FreeList = ( { list } ) => {
return (
-
- { list.map(
- ( {
- description,
- fixedIn,
- icon,
- id,
- label,
- name,
- source,
- table,
- title,
- type,
- version,
- } ) => (
-
- )
+
+ { ( { currentItems } ) => (
+
+ { currentItems.map(
+ ( {
+ description,
+ fixedIn,
+ icon,
+ id,
+ label,
+ name,
+ source,
+ table,
+ title,
+ type,
+ version,
+ } ) => (
+
+ )
+ ) }
+
) }
-
+
);
};
diff --git a/projects/plugins/protect/src/js/components/threats-list/pagination.jsx b/projects/plugins/protect/src/js/components/threats-list/pagination.jsx
new file mode 100644
index 0000000000000..3e17bed0eeac4
--- /dev/null
+++ b/projects/plugins/protect/src/js/components/threats-list/pagination.jsx
@@ -0,0 +1,142 @@
+import { Button, useBreakpointMatch } from '@automattic/jetpack-components';
+import { __, sprintf } from '@wordpress/i18n';
+import { chevronLeft, chevronRight } from '@wordpress/icons';
+import React, { useCallback, useState, useMemo } from 'react';
+import styles from './styles.module.scss';
+
+const PaginationButton = ( { pageNumber, currentPage, onPageChange } ) => {
+ const isCurrentPage = useMemo( () => currentPage === pageNumber, [ currentPage, pageNumber ] );
+
+ const handleClick = useCallback( () => {
+ onPageChange( pageNumber );
+ }, [ onPageChange, pageNumber ] );
+
+ return (
+
+ );
+};
+
+const Pagination = ( { list, itemPerPage = 10, children } ) => {
+ const [ isSm ] = useBreakpointMatch( 'sm' );
+
+ const [ currentPage, setCurrentPage ] = useState( 1 );
+
+ const handlePreviousPageClick = useCallback(
+ () => setCurrentPage( currentPage - 1 ),
+ [ currentPage, setCurrentPage ]
+ );
+ const handleNextPageClick = useCallback(
+ () => setCurrentPage( currentPage + 1 ),
+ [ currentPage, setCurrentPage ]
+ );
+
+ const totalPages = useMemo( () => Math.ceil( list.length / itemPerPage ), [ list, itemPerPage ] );
+
+ const currentItems = useMemo( () => {
+ const indexOfLastItem = currentPage * itemPerPage;
+ const indexOfFirstItem = indexOfLastItem - itemPerPage;
+ return list.slice( indexOfFirstItem, indexOfLastItem );
+ }, [ currentPage, list, itemPerPage ] );
+
+ const pageNumbers = useMemo( () => {
+ if ( isSm ) {
+ return [ currentPage ];
+ }
+
+ const result = [ 1 ];
+ if ( currentPage > 3 && totalPages > 4 ) {
+ result.push( '…' );
+ }
+
+ if ( currentPage === 1 ) {
+ // Current page is the first page.
+ // i.e. [ 1 ] 2 3 4 ... 10
+ result.push( currentPage + 1, currentPage + 2, currentPage + 3 );
+ } else if ( currentPage === 2 ) {
+ // Current page is the second to first page.
+ // i.e. 1 [ 2 ] 3 4 ... 10
+ result.push( currentPage, currentPage + 1, currentPage + 2 );
+ } else if ( currentPage < totalPages - 1 ) {
+ // Current page is positioned in the middle of the pagination.
+ // i.e. 1 ... 3 [ 4 ] 5 ... 10
+ result.push( currentPage - 1, currentPage, currentPage + 1 );
+ } else if ( currentPage === totalPages - 1 ) {
+ // Current page is the second to last page.
+ // i.e. 1 ... 7 8 [ 9 ] 10
+ currentPage > 3 && result.push( currentPage - 2 );
+ currentPage > 2 && result.push( currentPage - 1 );
+ result.push( currentPage );
+ } else if ( currentPage === totalPages ) {
+ // Current page is the last page.
+ // i.e. 1 ... 7 8 9 [ 10 ]
+ currentPage >= 5 && result.push( currentPage - 3 );
+ currentPage >= 4 && result.push( currentPage - 2 );
+ result.push( currentPage - 1 );
+ }
+
+ if ( result[ result.length - 1 ] < totalPages - 1 ) {
+ result.push( '…' );
+ result.push( totalPages );
+ } else if ( result[ result.length - 1 ] < totalPages ) {
+ result.push( totalPages );
+ }
+
+ return result.filter( pageNumber => pageNumber <= totalPages || isNaN( pageNumber ) );
+ }, [ currentPage, isSm, totalPages ] );
+
+ return (
+ <>
+ { children( { currentItems } ) }
+ { totalPages > 1 && (
+
+ ) }
+ >
+ );
+};
+
+export default Pagination;
diff --git a/projects/plugins/protect/src/js/components/threats-list/paid-list.jsx b/projects/plugins/protect/src/js/components/threats-list/paid-list.jsx
index 24cbd217c74d8..5d12a4329d15d 100644
--- a/projects/plugins/protect/src/js/components/threats-list/paid-list.jsx
+++ b/projects/plugins/protect/src/js/components/threats-list/paid-list.jsx
@@ -7,6 +7,7 @@ import { STORE_ID } from '../../state/store';
import DiffViewer from '../diff-viewer';
import MarkedLines from '../marked-lines';
import PaidAccordion, { PaidAccordionItem } from '../paid-accordion';
+import Pagination from './pagination';
import styles from './styles.module.scss';
const ThreatAccordionItem = ( {
@@ -177,62 +178,66 @@ const PaidList = ( { list, hideAutoFixColumn = false } ) => {
return (
<>
{ ! isSmall && (
-
+
{ __( 'Details', 'jetpack-protect' ) }
{ __( 'Severity', 'jetpack-protect' ) }
{ ! hideAutoFixColumn && { __( 'Auto-fix', 'jetpack-protect' ) } }
) }
-
- { list.map(
- ( {
- context,
- description,
- diff,
- filename,
- firstDetected,
- fixedIn,
- fixedOn,
- icon,
- fixable,
- id,
- label,
- name,
- severity,
- source,
- table,
- title,
- type,
- version,
- status,
- } ) => (
-
- )
+
+ { ( { currentItems } ) => (
+
+ { currentItems.map(
+ ( {
+ context,
+ description,
+ diff,
+ filename,
+ firstDetected,
+ fixedIn,
+ fixedOn,
+ icon,
+ fixable,
+ id,
+ label,
+ name,
+ severity,
+ source,
+ table,
+ title,
+ type,
+ version,
+ status,
+ } ) => (
+
+ )
+ ) }
+
) }
-
+
>
);
};
diff --git a/projects/plugins/protect/src/js/components/threats-list/styles.module.scss b/projects/plugins/protect/src/js/components/threats-list/styles.module.scss
index 6942d4ecf7dc7..4a50d87b2562b 100644
--- a/projects/plugins/protect/src/js/components/threats-list/styles.module.scss
+++ b/projects/plugins/protect/src/js/components/threats-list/styles.module.scss
@@ -57,7 +57,7 @@
}
}
-.accordion-heading {
+.accordion-header {
display: grid;
grid-template-columns: repeat( 9, 1fr );
background-color: white;
@@ -99,3 +99,31 @@
}
}
}
+
+.pagination-container {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ gap: 4px;
+ margin-top: calc( var( --spacing-base ) * 4 ); // 24px
+ margin-bottom: calc(var(--spacing-base) * 2); // 16px
+
+ button {
+ font-size: var( --font-body );
+ width: auto;
+ height: auto;
+ padding: 0 var( --spacing-base ); // 0 | 8px
+ line-height: 32px;
+ min-width: 32px;
+
+ &.unfocused {
+ color: var( --jp-black );
+ background: none;
+
+ &:hover:not(:disabled) {
+ color: var( --jp-black );
+ background: none;
+ }
+ }
+ }
+}