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/changelog/update-components-threats-data-view-bulk-action-support b/projects/js-packages/components/changelog/update-components-threats-data-view-bulk-action-support new file mode 100644 index 0000000000000..3c575312c760a --- /dev/null +++ b/projects/js-packages/components/changelog/update-components-threats-data-view-bulk-action-support @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Adds bulk actions to the ThreastDataViews header diff --git a/projects/js-packages/components/components/threat-fixer-button/index.tsx b/projects/js-packages/components/components/threat-fixer-button/index.tsx index 7c1c92fcc718a..133067d7148fb 100644 --- a/projects/js-packages/components/components/threat-fixer-button/index.tsx +++ b/projects/js-packages/components/components/threat-fixer-button/index.tsx @@ -21,7 +21,7 @@ export default function ThreatFixerButton( { onClick, }: { threat: Threat; - onClick: ( items: Threat[] ) => void; + onClick: ( items: ( string | number )[] ) => void; className?: string; } ): JSX.Element { const [ isPopoverVisible, setIsPopoverVisible ] = useState( false ); @@ -66,7 +66,7 @@ export default function ThreatFixerButton( { setIsPopoverVisible( true ); return; } - onClick( [ threat ] ); + onClick( [ threat.id ] ); }, [ onClick, errorMessage, isPopoverVisible, threat ] ); 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..ccead1d5e4a12 100644 --- a/projects/js-packages/components/components/threats-data-views/index.tsx +++ b/projects/js-packages/components/components/threats-data-views/index.tsx @@ -1,18 +1,22 @@ -import { getThreatType, type Threat } from '@automattic/jetpack-scan'; +import { getThreatType, type Threat, type ThreatStatus } from '@automattic/jetpack-scan'; import { - type Action, - type ActionButton, - type Field, - type FieldType, - type Filter, - type SortDirection, - type SupportedLayouts, - type View, + Button, + __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 { + Action, DataViews, + Field, + FieldType, + Filter, filterSortAndPaginate, + SortDirection, + SupportedLayouts, + type View, } from '@wordpress/dataviews'; import { dateI18n } from '@wordpress/date'; -import { __ } from '@wordpress/i18n'; +import { __, sprintf } from '@wordpress/i18n'; import { Icon } from '@wordpress/icons'; import { useCallback, useMemo, useState } from 'react'; import Badge from '../badge'; @@ -42,13 +46,89 @@ import { } from './constants'; import styles from './styles.module.scss'; +type ThreatIdsByAction = { + fixable: ( string | number )[]; + ignorable: ( string | number )[]; + unignorable: ( string | number )[]; +}; + +/** + * ToggleGroupControl component for filtering threats by status. + * @param {object} props - Component props. + * @param {number} props.activeCount - Number of active threats. + * @param {number} props.historicCount - Number of historic threats. + * @param {boolean} props.isViewingActiveThreats - Whether the active status is selected. + * @param {boolean} props.isViewingHistoricThreats - Whether the historic status is selected. + * @param {Function} props.onStatusFilterChange - Callback function to handle the status filter change. + * @return {JSX.Element|null} The component or null. + */ +export function ThreatsStatusToggleGroupControl( { + activeCount, + historicCount, + isViewingActiveThreats, + isViewingHistoricThreats, + onStatusFilterChange, +}: { + activeCount: number; + historicCount: number; + isViewingActiveThreats: boolean; + isViewingHistoricThreats: boolean; + onStatusFilterChange: ( newValue: string ) => void; +} ): JSX.Element { + if ( ! ( activeCount + historicCount ) ) { + return null; + } + + let selectedValue = ''; + if ( isViewingActiveThreats ) { + selectedValue = 'active'; + } else if ( isViewingHistoricThreats ) { + selectedValue = 'historic'; + } + + return ( + + + { sprintf( + /* translators: %d: number of active threats */ __( + 'Active threats (%d)', + 'jetpack' + ), + activeCount + ) } + + } + /> + + { sprintf( + /* translators: %d: number of historic threats */ + __( 'History (%d)', 'jetpack' ), + historicCount + ) } + + } + /> + + ); +} + /** * DataViews component for displaying security threats. * * @param {object} props - Component props. * @param {Array} props.data - Threats data. * @param {Array} props.filters - Initial DataView filters. - * @param {Function} props.onChangeSelection - Callback function run when an item is selected. * @param {Function} props.onFixThreats - Threat fix action callback. * @param {Function} props.onIgnoreThreats - Threat ignore action callback. * @param {Function} props.onUnignoreThreats - Threat unignore action callback. @@ -61,7 +141,6 @@ import styles from './styles.module.scss'; export default function ThreatsDataViews( { data, filters, - onChangeSelection, isThreatEligibleForFix, isThreatEligibleForIgnore, isThreatEligibleForUnignore, @@ -71,13 +150,12 @@ export default function ThreatsDataViews( { }: { data: Threat[]; filters?: Filter[]; - onChangeSelection?: ( selectedItemIds: string[] ) => void; isThreatEligibleForFix?: ( threat: Threat ) => boolean; isThreatEligibleForIgnore?: ( threat: Threat ) => boolean; isThreatEligibleForUnignore?: ( threat: Threat ) => boolean; - onFixThreats?: ( threats: Threat[] ) => void; - onIgnoreThreats?: ActionButton< Threat >[ 'callback' ]; - onUnignoreThreats?: ActionButton< Threat >[ 'callback' ]; + onFixThreats?: ( threatIds: ( string | number )[] ) => void; + onIgnoreThreats?: ( threatIds: ( string | number )[] ) => void; + onUnignoreThreats?: ( threatIds: ( string | number )[] ) => void; } ): JSX.Element { const baseView = { sort: { @@ -133,6 +211,12 @@ export default function ThreatsDataViews( { }, }; + const [ selectedThreatIds, setSelectedThreatIds ] = useState< ThreatIdsByAction >( { + fixable: [], + ignorable: [], + unignorable: [], + } ); + /** * DataView view object - configures how the dataset is visible to the user. * @@ -143,20 +227,43 @@ export default function ThreatsDataViews( { ...defaultLayouts.table, } ); + /** + * 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 ] + ); + /** * 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 {number} activeThreatsCount - Count of active threats. + * @member {number} historicThreatsCount - Count of historic threats. + * @member {object[]} themes - List of unique threat themes. + * @member {object[]} plugins - List of unique threat plugins. * @member {object[]} signatures - List of unique threat signatures. - * @member {string[]} dataFields - List of unique fields. + * @member {Array} dataFields - List of unique fields. */ const { + activeThreatsCount, + historicThreatsCount, themes, plugins, signatures, dataFields, }: { + activeThreatsCount: number; + historicThreatsCount: number; themes: { value: string; label: string }[]; plugins: { value: string; label: string }[]; signatures: { value: string; label: string }[]; @@ -164,6 +271,15 @@ export default function ThreatsDataViews( { } = useMemo( () => { return data.reduce( ( acc, threat ) => { + // Active/Historic Threats + if ( threat.status ) { + if ( threat.status === 'current' ) { + acc.activeThreatsCount++; + } else { + acc.historicThreatsCount++; + } + } + // Extensions (Themes and Plugins) if ( threat.extension ) { switch ( threat.extension.type ) { @@ -204,6 +320,8 @@ export default function ThreatsDataViews( { return acc; }, { + activeThreatsCount: 0, + historicThreatsCount: 0, themes: [], plugins: [], signatures: [], @@ -426,10 +544,14 @@ export default function ThreatsDataViews( { result.push( { id: THREAT_ACTION_FIX, label: __( 'Auto-Fix', 'jetpack' ), - isPrimary: true, supportsBulk: true, - callback: onFixThreats, + callback: items => onFixThreats( items.map( item => item.id ) ), isEligible( item ) { + // TODO: Account for this here, or in isThreatEligibleForFix? + if ( item.fixer && 'status' in item.fixer && item.fixer.status !== 'not_started' ) { + return false; + } + if ( ! onFixThreats ) { return false; } @@ -445,10 +567,14 @@ export default function ThreatsDataViews( { result.push( { id: THREAT_ACTION_IGNORE, label: __( 'Ignore', 'jetpack' ), - isPrimary: true, - isDestructive: true, - callback: onIgnoreThreats, + supportsBulk: true, + callback: items => onIgnoreThreats( items.map( item => item.id ) ), isEligible( item ) { + // TODO: Account for this here, or in isThreatEligibleForIgnore? + if ( item.fixer && 'status' in item.fixer && item.fixer.status !== 'not_started' ) { + return false; + } + if ( ! onIgnoreThreats ) { return false; } @@ -463,10 +589,9 @@ export default function ThreatsDataViews( { if ( dataFields.includes( 'status' ) ) { result.push( { id: THREAT_ACTION_UNIGNORE, - label: __( 'Unignore', 'jetpack' ), - isPrimary: true, - isDestructive: true, - callback: onUnignoreThreats, + label: __( 'Un-ignore', 'jetpack' ), + supportsBulk: true, + callback: items => onUnignoreThreats( items.map( item => item.id ) ), isEligible( item ) { if ( ! onUnignoreThreats ) { return false; @@ -499,6 +624,41 @@ export default function ThreatsDataViews( { return filterSortAndPaginate( data, view, fields ); }, [ data, view, fields ] ); + /** + * Callback function to update the selected threats states on selection change. + * + * @see https://developer.wordpress.org/block-editor/reference-guides/packages/packages-dataviews/#onchangeselection-function + */ + const onChangeSelection = useCallback( + ( selectedItemIds: string[] ) => { + const fixableIds: ( string | number )[] = []; + const ignorableIds: ( string | number )[] = []; + const unignorableIds: ( string | number )[] = []; + + selectedItemIds.forEach( id => { + const threat = data.find( item => item.id === parseInt( id, 10 ) ); + if ( threat ) { + if ( threat.status === 'current' ) { + ignorableIds.push( threat.id ); + + if ( threat.fixable ) { + fixableIds.push( threat.id ); + } + } else if ( threat.status === 'ignored' ) { + unignorableIds.push( threat.id ); + } + } + } ); + + setSelectedThreatIds( { + fixable: fixableIds, + ignorable: ignorableIds, + unignorable: unignorableIds, + } ); + }, + [ data ] + ); + /** * Callback function to update the view state. * @@ -515,6 +675,49 @@ export default function ThreatsDataViews( { */ const getItemId = useCallback( ( item: Threat ) => item.id.toString(), [] ); + /** + * 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' ], + } ); + } + + setView( { + ...view, + filters: updatedFilters, + } ); + }, + [ view ] + ); + + const handleBulkFixThreatsClick = useCallback( () => { + onFixThreats( selectedThreatIds.fixable ); + }, [ onFixThreats, selectedThreatIds.fixable ] ); + + const handleBulkIgnoreThreatsClick = useCallback( () => { + onIgnoreThreats( selectedThreatIds.ignorable ); + }, [ onIgnoreThreats, selectedThreatIds.ignorable ] ); + + const handleBulkUnignoreThreatsClick = useCallback( () => { + onUnignoreThreats( selectedThreatIds.unignorable ); + }, [ onUnignoreThreats, selectedThreatIds.unignorable ] ); + return ( + + { selectedThreatIds.fixable.length > 0 && ( + + ) } + { selectedThreatIds.ignorable.length > 0 && ( + + ) } + { selectedThreatIds.unignorable.length > 0 && ( + + ) } + + } /> ); } 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..6f4764210d0cb 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, @@ -113,6 +113,7 @@ Default.args = { file: '/var/www/html/wp-admin/index.php', extensionStatus: '', }, + fixer: { status: 'not_started' }, filename: '/var/www/html/wp-admin/index.php', diff: "--- /tmp/wordpress/6.6.2/wordpress/wp-admin/index.php\t2024-10-07 20:40:04.887546480 +0000\n+++ /var/www/html/wp-admin/index.php\t2024-10-07 20:39:58.775512965 +0000\n@@ -210,3 +210,4 @@\n wp_print_community_events_templates();\n \n require_once ABSPATH . 'wp-admin/admin-footer.php';\n+if ( true === false ) exit();\n\\ No newline at end of file\n", }, @@ -153,14 +154,26 @@ Default.args = { value: [ 'current' ], }, ], - onFixThreats: () => - alert( 'Threat fix action callback triggered! This is handled by the component consumer.' ), // eslint-disable-line no-alert - onIgnoreThreats: () => - alert( 'Ignore threat action callback triggered! This is handled by the component consumer.' ), // eslint-disable-line no-alert - onUnignoreThreats: () => + onFixThreats: threats => // eslint-disable-next-line no-alert alert( - 'Unignore threat action callback triggered! This is handled by the component consumer.' + `Fix threats action callback triggered for threats IDs: ${ threats + .map( threat => threat ) + .join( ', ' ) }. This is handled by the component consumer.` + ), + onIgnoreThreats: threats => + // eslint-disable-next-line no-alert + alert( + `Ignore threats action callback triggered for threats IDs: ${ threats + .map( threat => threat ) + .join( ', ' ) }. This is handled by the component consumer.` + ), + onUnignoreThreats: threats => + // eslint-disable-next-line no-alert + alert( + `Un-ignore threats action callback triggered for threats IDs: ${ threats + .map( threat => threat ) + .join( ', ' ) }. This is handled by the component consumer.` ), }; @@ -176,7 +189,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 +203,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 +222,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: { @@ -280,7 +293,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 +308,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 +322,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, }, ];