diff --git a/projects/js-packages/components/changelog/add-component-threats-data-view-toggle-group-control b/projects/js-packages/components/changelog/add-component-threats-data-view-toggle-group-control
new file mode 100644
index 0000000000000..9c528d1825437
--- /dev/null
+++ b/projects/js-packages/components/changelog/add-component-threats-data-view-toggle-group-control
@@ -0,0 +1,4 @@
+Significance: minor
+Type: changed
+
+Add ToggleGroupControl to ThreatsDataViews for easily toggling between Active and Historical threats
diff --git a/projects/js-packages/components/components/threats-data-views/index.tsx b/projects/js-packages/components/components/threats-data-views/index.tsx
index c041e31699f42..f141f223c07dc 100644
--- a/projects/js-packages/components/components/threats-data-views/index.tsx
+++ b/projects/js-packages/components/components/threats-data-views/index.tsx
@@ -41,6 +41,7 @@ import {
THREAT_TYPES,
} from './constants';
import styles from './styles.module.scss';
+import ThreatsStatusToggleGroupControl from './threats-status-toggle-group-control';
/**
* DataViews component for displaying security threats.
@@ -146,10 +147,10 @@ export default function ThreatsDataViews( {
/**
* Compute values from the provided threats data.
*
- * @member {object[]} themes - List of unique themes included in the threats data.
- * @member {object[]} plugins - List of unique plugins included in the threats data.
+ * @member {object[]} themes - List of unique themes included in the threats data.
+ * @member {object[]} plugins - plugins included in the threats data.
* @member {object[]} signatures - List of unique threat signatures.
- * @member {string[]} dataFields - List of unique fields.
+ * @member {string[]} dataFields - List of unique fields.
*/
const {
themes,
@@ -526,6 +527,13 @@ export default function ThreatsDataViews( {
onChangeView={ onChangeView }
paginationInfo={ paginationInfo }
view={ view }
+ header={
+
+ }
/>
);
}
diff --git a/projects/js-packages/components/components/threats-data-views/stories/index.stories.tsx b/projects/js-packages/components/components/threats-data-views/stories/index.stories.tsx
index 00a493eea73de..46ca286e1d13e 100644
--- a/projects/js-packages/components/components/threats-data-views/stories/index.stories.tsx
+++ b/projects/js-packages/components/components/threats-data-views/stories/index.stories.tsx
@@ -87,7 +87,7 @@ Default.args = {
fixedIn: '1.12.4',
severity: 3,
fixable: { fixer: 'update', target: '1.12.4', extensionStatus: 'inactive' },
- fixer: { status: 'in_progress', last_updated: new Date().toISOString() },
+ fixer: { status: 'in_progress', lastUpdated: new Date().toISOString() },
status: 'current',
filename: null,
context: null,
@@ -176,7 +176,7 @@ FixerStatuses.args = {
severity: 4,
fixer: null,
fixedOn: '2024-07-15T22:01:42.000Z',
- status: 'fixed',
+ status: 'current',
fixable: { fixer: 'update', target: '6.4.4', extensionStatus: 'inactive' },
version: '6.4.3',
source: '',
@@ -190,7 +190,7 @@ FixerStatuses.args = {
fixedIn: '1.2.4',
severity: 3,
fixable: { fixer: 'update', target: '1.12.4', extensionStatus: 'inactive' },
- fixer: { status: 'in_progress', last_updated: new Date().toISOString() },
+ fixer: { status: 'in_progress', lastUpdated: new Date().toISOString() },
status: 'current',
source: 'https://wpscan.com/vulnerability/733d8a02-0d44-4b78-bbb2-37e447acd2f3',
extension: {
@@ -209,7 +209,7 @@ FixerStatuses.args = {
fixedIn: '2.22.22',
severity: 3,
fixable: { fixer: 'update', target: '1.12.4', extensionStatus: 'inactive' },
- fixer: { status: 'in_progress', last_updated: new Date( '1999-01-01' ).toISOString() },
+ fixer: { status: 'in_progress', lastUpdated: new Date( '1999-01-01' ).toISOString() },
status: 'current',
source: 'https://wpscan.com/vulnerability/733d8a02-0d44-4b78-bbb2-37e447acd2f3',
extension: {
@@ -260,6 +260,13 @@ FixerStatuses.args = {
},
},
],
+ filters: [
+ {
+ field: 'status',
+ operator: 'isAny',
+ value: [ 'current' ],
+ },
+ ],
onFixThreats: () =>
alert( 'Threat fix action callback triggered! This is handled by the component consumer.' ), // eslint-disable-line no-alert
onIgnoreThreats: () =>
@@ -280,7 +287,6 @@ FreeResults.args = {
description:
'Versions 3.2.3 and earlier are affected by an issue where cached queries within shortcodes could lead to object injection. This is related to the recent WordPress 4.8.3 security release.This issue can only be exploited by users who can edit content and add shortcodes, but we still recommend all users running WooCommerce 3.x upgrade to 3.2 to mitigate this issue.',
fixedIn: '3.2.4',
- status: 'current',
source: 'https://wpscan.com/vulnerability/1d0470df-4671-47ac-8d87-a165e8f7d502',
extension: {
name: 'WooCommerce',
@@ -296,7 +302,6 @@ FreeResults.args = {
description:
'The WooCommerce WordPress plugin was affected by an Authenticated Stored XSS security vulnerability.',
fixedIn: '3.4.6',
- status: 'current',
source: 'https://wpscan.com/vulnerability/7275a176-d579-471a-8492-df8edbdf27de',
extension: {
name: 'WooCommerce',
@@ -311,7 +316,6 @@ FreeResults.args = {
description:
'The plugin was affected by an authenticated (admin+) RCE in the settings page due to input validation failure and weak $cache_path check in the WP Super Cache Settings -> Cache Location option. Direct access to the wp-cache-config.php file is not prohibited, so this vulnerability can be exploited for a web shell injection.\r\n\r\nAnother possible attack vector: from XSS (via another plugin affected by XSS) to RCE.',
fixedIn: '1.7.2',
- status: 'current',
source: 'https://wpscan.com/vulnerability/733d8a02-0d44-4b78-bbb2-37e447acd2f3',
extension: {
name: 'WP Super Cache',
diff --git a/projects/js-packages/components/components/threats-data-views/styles.module.scss b/projects/js-packages/components/components/threats-data-views/styles.module.scss
index 98789b2f794ec..820b07916687b 100644
--- a/projects/js-packages/components/components/threats-data-views/styles.module.scss
+++ b/projects/js-packages/components/components/threats-data-views/styles.module.scss
@@ -31,6 +31,11 @@
border-color: #EDFFEE;
svg {
- fill: black;
+ fill: var( --jp-black );
}
}
+
+.toggle-group-control__option {
+ white-space: nowrap;
+ padding: 0 12px;
+}
diff --git a/projects/js-packages/components/components/threats-data-views/test/index.test.tsx b/projects/js-packages/components/components/threats-data-views/test/index.test.tsx
index e6bcedd84ebdd..2e7dfea35d673 100644
--- a/projects/js-packages/components/components/threats-data-views/test/index.test.tsx
+++ b/projects/js-packages/components/components/threats-data-views/test/index.test.tsx
@@ -42,7 +42,6 @@ const data = [
type: 'plugin' as const,
},
fixedIn: '3.2.4',
- status: 'current' as const,
},
];
diff --git a/projects/js-packages/components/components/threats-data-views/threats-status-toggle-group-control.tsx b/projects/js-packages/components/components/threats-data-views/threats-status-toggle-group-control.tsx
new file mode 100644
index 0000000000000..d9282cc07839a
--- /dev/null
+++ b/projects/js-packages/components/components/threats-data-views/threats-status-toggle-group-control.tsx
@@ -0,0 +1,160 @@
+import { type Threat, type ThreatStatus } from '@automattic/jetpack-scan';
+import {
+ __experimentalToggleGroupControl as ToggleGroupControl, // eslint-disable-line @wordpress/no-unsafe-wp-apis
+ __experimentalToggleGroupControlOption as ToggleGroupControlOption, // eslint-disable-line @wordpress/no-unsafe-wp-apis
+} from '@wordpress/components';
+import { type View } from '@wordpress/dataviews';
+import { useMemo, useCallback } from '@wordpress/element';
+import { __, sprintf } from '@wordpress/i18n';
+import styles from './styles.module.scss';
+
+/**
+ * ToggleGroupControl component for filtering threats by status.
+ * @param {object} props - Component props.
+ * @param { Threat[]} props.data - Threats data.
+ * @param { View } props.view - The current view.
+ * @param { Function } props.onChangeView - Callback function to handle view changes.
+ * @return {JSX.Element|null} The component or null.
+ */
+export default function ThreatsStatusToggleGroupControl( {
+ data,
+ view,
+ onChangeView,
+}: {
+ data: Threat[];
+ view: View;
+ onChangeView: ( newView: View ) => void;
+} ): JSX.Element {
+ /**
+ * Compute values from the provided threats data.
+ *
+ * @member {number} activeThreatsCount - Count of active threats.
+ * @member {number} historicThreatsCount - Count of historic threats.
+ */
+ const {
+ activeThreatsCount,
+ historicThreatsCount,
+ }: {
+ activeThreatsCount: number;
+ historicThreatsCount: number;
+ } = useMemo( () => {
+ return data.reduce(
+ ( acc, threat ) => {
+ if ( threat.status ) {
+ if ( threat.status === 'current' ) {
+ acc.activeThreatsCount++;
+ } else {
+ acc.historicThreatsCount++;
+ }
+ }
+ return acc;
+ },
+ {
+ activeThreatsCount: 0,
+ historicThreatsCount: 0,
+ }
+ );
+ }, [ data ] );
+
+ /**
+ * Callback function to handle the status change filter.
+ *
+ * @param {string} newStatus - The new status filter value.
+ */
+ const onStatusFilterChange = useCallback(
+ ( newStatus: string ) => {
+ const updatedFilters = view.filters.filter( filter => filter.field !== 'status' );
+
+ if ( newStatus === 'active' ) {
+ updatedFilters.push( {
+ field: 'status',
+ operator: 'isAny',
+ value: [ 'current' ],
+ } );
+ } else if ( newStatus === 'historic' ) {
+ updatedFilters.push( {
+ field: 'status',
+ operator: 'isAny',
+ value: [ 'fixed', 'ignored' ],
+ } );
+ }
+
+ onChangeView( {
+ ...view,
+ filters: updatedFilters,
+ } );
+ },
+ [ view, onChangeView ]
+ );
+
+ /**
+ * Memoized function to determine if a status filter is selected.
+ *
+ * @param {Array} threatStatuses - List of threat statuses.
+ */
+ const isStatusFilterSelected = useMemo(
+ () => ( threatStatuses: ThreatStatus[] ) =>
+ view.filters.some(
+ filter =>
+ filter.field === 'status' &&
+ Array.isArray( filter.value ) &&
+ filter.value.length === threatStatuses.length &&
+ threatStatuses.every( threatStatus => filter.value.includes( threatStatus ) )
+ ),
+ [ view.filters ]
+ );
+
+ const selectedValue = useMemo( () => {
+ if ( isStatusFilterSelected( [ 'current' ] ) ) {
+ return 'active' as const;
+ }
+ if ( isStatusFilterSelected( [ 'fixed', 'ignored' ] ) ) {
+ return 'historic' as const;
+ }
+ return '' as const;
+ }, [ isStatusFilterSelected ] );
+
+ if ( ! ( activeThreatsCount + historicThreatsCount ) ) {
+ return null;
+ }
+
+ try {
+ return (
+
+
+ { sprintf(
+ /* translators: %d: number of active threats */ __(
+ 'Active threats (%d)',
+ 'jetpack'
+ ),
+ activeThreatsCount
+ ) }
+
+ }
+ />
+
+ { sprintf(
+ /* translators: %d: number of historic threats */
+ __( 'History (%d)', 'jetpack' ),
+ historicThreatsCount
+ ) }
+
+ }
+ />
+
+ );
+ } catch ( error ) {
+ return null;
+ }
+}