diff --git a/projects/js-packages/components/components/threats-data-view/fixer-status.tsx b/projects/js-packages/components/components/threats-data-view/fixer-status.tsx index dfdaa7920c55c..35f61d4358d83 100644 --- a/projects/js-packages/components/components/threats-data-view/fixer-status.tsx +++ b/projects/js-packages/components/components/threats-data-view/fixer-status.tsx @@ -1,4 +1,5 @@ import { ExternalLink, Spinner } from '@wordpress/components'; +import { View } from '@wordpress/dataviews'; import { createInterpolateElement } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { Icon } from '@wordpress/icons'; @@ -14,16 +15,23 @@ import { fixerStatusIsStale } from './utils'; * * @param {object} props - Component props. * @param {boolean} props.fixer - The fixer status. + * @param {number} props.size - The size of the icon. * * @return {JSX.Element} The component. */ -export default function FixerStatus( { fixer }: { fixer?: ThreatFixStatus } ): JSX.Element { +export default function FixerStatusIcon( { + fixer, + size = 24, +}: { + fixer?: ThreatFixStatus; + size?: number; +} ): JSX.Element { if ( fixer && fixerStatusIsStale( fixer ) ) { return ( contact support.', @@ -72,3 +80,61 @@ export default function FixerStatus( { fixer }: { fixer?: ThreatFixStatus } ): J return ; } + +/** + * FixerStatusText component. + * @param {object} props - Component props. + * @param {boolean} props.fixer - The fixer status. + * @return {string} The component. + */ +function FixerStatusText( { fixer }: { fixer?: ThreatFixStatus } ): string { + if ( fixer && fixerStatusIsStale( fixer ) ) { + return __( 'Fixer is taking longer than expected', 'jetpack' ); + } + + if ( fixer && 'error' in fixer && fixer.error ) { + return __( 'Error auto-fixing threat', 'jetpack' ); + } + + if ( fixer && 'status' in fixer && fixer.status === 'in_progress' ) { + return __( 'Auto-fix in progress', 'jetpack' ); + } + + return __( 'Auto-fixable', 'jetpack' ); +} + +/** + * FixerStatusBadge component. + * @param {object} props - Component props. + * @param {boolean} props.fixer - The fixer status. + * @return {string} The component. + */ +export function FixerStatusBadge( { fixer }: { fixer?: ThreatFixStatus } ): JSX.Element { + return ( +
+ + +
+ ); +} + +/** + * FixerStatusText component. + * @param {object} props - Component props. + * @param {boolean} props.fixer - The fixer status. + * @param {object} props.view - The view. + * @return {string} The component. + */ +export function DataViewFixerStatus( { + fixer, + view, +}: { + fixer?: ThreatFixStatus; + view: View; +} ): JSX.Element { + if ( view.type === 'table' ) { + return ; + } + + return ; +} diff --git a/projects/js-packages/components/components/threats-data-view/index.tsx b/projects/js-packages/components/components/threats-data-view/index.tsx index 5e3ec600a3ff7..3e60a3f85ff4f 100644 --- a/projects/js-packages/components/components/threats-data-view/index.tsx +++ b/projects/js-packages/components/components/threats-data-view/index.tsx @@ -9,13 +9,14 @@ import { SupportedLayouts, type View, } from '@wordpress/dataviews'; -import { __ } from '@wordpress/i18n'; +import { __, _x } from '@wordpress/i18n'; +import clsx from 'clsx'; import { useCallback, useMemo, useState } from 'react'; import { THREAT_STATUSES, THREAT_TYPES } from './constants'; -import FixerStatus from './fixer-status'; +import { DataViewFixerStatus } from './fixer-status'; import styles from './styles.module.scss'; import { DataViewThreat, ThreatsDataViewActionCallback } from './types'; -import { getThreatIcon, getThreatSubtitle } from './utils'; +import { getThreatIcon, getThreatSubtitle, getThreatType } from './utils'; /** * DataView component for displaying security threats. @@ -51,6 +52,37 @@ export default function ThreatsDataView( { onIgnoreThreat?: ThreatsDataViewActionCallback; onUnignoreThreat?: ThreatsDataViewActionCallback; } ): JSX.Element { + /** + * DataView default layouts. + * + * This property provides layout information about the view types that are active. If empty, enables all layout types (see “Layout Types”) with empty layout data. + * + * @see https://developer.wordpress.org/block-editor/reference-guides/packages/packages-dataviews/#defaultlayouts-record-string-view + */ + const defaultLayouts: SupportedLayouts = { + table: { + fields: [ 'severity', 'threat', 'auto-fix' ], + layout: { + primaryField: 'severity', + combinedFields: [ + { + id: 'threat', + label: __( 'Threat', 'jetpack' ), + children: [ 'subtitle', 'title', 'description' ], + direction: 'vertical', + }, + ], + }, + }, + list: { + layout: { + primaryField: 'title', + mediaField: 'icon', + }, + fields: [ 'severity', 'subtitle', 'signature', 'auto-fix' ], + }, + }; + /** * DataView view object - configures how the dataset is visible to the user. * @@ -58,6 +90,7 @@ export default function ThreatsDataView( { */ const [ view, setView ] = useState< View >( { type: 'table', + ...defaultLayouts.table, search: '', filters: filters || [], page: 1, @@ -66,8 +99,6 @@ export default function ThreatsDataView( { field: 'severity', direction: 'desc', }, - fields: [ 'severity', 'threat', 'auto-fix' ], - layout: {}, } ); /** @@ -89,8 +120,8 @@ export default function ThreatsDataView( { // Signatures if ( threat?.signature ) { - if ( ! acc.extensions.find( ( { value } ) => value === threat.signature ) ) { - acc.extensions.push( { value: threat.signature, label: threat.signature } ); + if ( ! acc.signatures.find( ( { value } ) => value === threat.signature ) ) { + acc.signatures.push( { value: threat.signature, label: threat.signature } ); } } @@ -122,109 +153,178 @@ export default function ThreatsDataView( { * @see https://developer.wordpress.org/block-editor/reference-guides/packages/packages-dataviews/#fields-object */ const fields = useMemo( () => { - const result: Field< DataViewThreat >[] = [ - { - id: 'threat', - label: __( 'Threat', 'jetpack' ), - enableHiding: false, - enableGlobalSearch: true, + const result: Field< DataViewThreat >[] = []; + + result.push( { + id: 'status', + label: __( 'Status', 'jetpack' ), + elements: THREAT_STATUSES, + getValue( { item }: { item: DataViewThreat } ) { + if ( ! item.status ) { + return 'current'; + } + return THREAT_STATUSES.find( ( { value } ) => value === item.status )?.value ?? item.status; + }, + } ); + + if ( dataFields.includes( 'severity' ) ) { + result.push( { + id: 'severity', + label: __( 'Severity', 'jetpack' ), getValue( { item }: { item: DataViewThreat } ) { - return `${ item.title } ${ item.description }`; + return item.severity ?? 0; }, render( { item }: { item: DataViewThreat } ) { - return ( -
- - - { getThreatSubtitle( item ) } - - - { item.title } - - { item.description } -
- ); + if ( view.type === 'list' ) { + if ( item.severity >= 5 ) { + return _x( + 'Critical Severity', + 'Severity label for issues rated 5 or higher.', + 'jetpack' + ); + } else if ( item.severity >= 3 && item.severity < 5 ) { + return _x( + 'High Severity', + 'Severity label for issues rated between 3 and 5.', + 'jetpack' + ); + } + return _x( 'Low Severity', 'Severity label for issues rated below 3.', 'jetpack' ); + } + + return ; }, + } ); + } + + result.push( { + id: 'extension', + label: __( 'Extension', 'jetpack' ), + enableGlobalSearch: true, + elements: extensions, + getValue( { item }: { item: DataViewThreat } ) { + return item.extension ? item.extension.slug : ''; }, - { - id: 'status', - label: __( 'Status', 'jetpack' ), - elements: THREAT_STATUSES, - getValue( { item }: { item: DataViewThreat } ) { + } ); + + result.push( { + id: 'type', + label: __( 'Category', 'jetpack' ), + elements: THREAT_TYPES, + getValue( { item }: { item: DataViewThreat } ) { + if ( 'signature' in item && item.signature === 'Vulnerable.WP.Core' ) { + return 'core'; + } + if ( 'extension' in item && item.extension ) { + return item.extension.type; + } + if ( 'filename' in item && item.filename ) { + return 'file'; + } + if ( 'table' in item && item.table ) { + return 'database'; + } + + return 'uncategorized'; + }, + } ); + + result.push( { + id: 'subtitle', + label: __( 'Affected Item', 'jetpack' ), + getValue( { item }: { item: DataViewThreat } ) { + return getThreatSubtitle( item ); + }, + render( { item }: { item: DataViewThreat } ) { + if ( view.type === 'table' ) { return ( - THREAT_STATUSES.find( ( { value } ) => value === item.status )?.value ?? item.status + + + { getThreatSubtitle( item ) } + ); - }, + } + + return getThreatSubtitle( item ); }, - { - id: 'extension', - label: __( 'Extension', 'jetpack' ), - enableGlobalSearch: true, - elements: extensions, - getValue( { item }: { item: DataViewThreat } ) { - return item.extension ? item.extension.slug : ''; - }, + } ); + + result.push( { + id: 'icon', + label: __( 'Icon', 'jetpack' ), + getValue( { item }: { item: DataViewThreat } ) { + return getThreatType( item ); }, - { - id: 'type', - label: __( 'Category', 'jetpack' ), - elements: THREAT_TYPES, - getValue( { item }: { item: DataViewThreat } ) { - if ( 'signature' in item && item.signature === 'Vulnerable.WP.Core' ) { - return 'core'; - } - if ( 'extension' in item && item.extension ) { - return item.extension.type; - } - if ( 'filename' in item && item.filename ) { - return 'file'; - } - if ( 'table' in item && item.table ) { - return 'database'; - } + render( { item }: { item: DataViewThreat } ) { + return ( +
= 5, + [ styles[ 'media--high' ] ]: item.severity >= 3 && item.severity < 5, + } ) } + > + +
+ ); + }, + enableHiding: false, + } ); - return 'uncategorized'; - }, + result.push( { + id: 'title', + label: __( 'Title', 'jetpack' ), + enableGlobalSearch: true, + enableHiding: false, + render( { item }: { item: DataViewThreat } ) { + if ( view.type === 'list' ) { + return item.title; + } + return ( + + { item.title } + + ); }, - ]; + } ); - if ( dataFields.includes( 'fixable' ) ) { + result.push( { + id: 'description', + label: __( 'Description', 'jetpack' ), + enableGlobalSearch: true, + enableHiding: false, + render( { item }: { item: DataViewThreat } ) { + return { item.description }; + }, + } ); + + if ( dataFields.includes( 'signature' ) ) { result.push( { - id: 'auto-fix', - label: __( 'Auto-fix', 'jetpack' ), + id: 'signature', + label: __( 'Signature', 'jetpack' ), + elements: signatures, + enableGlobalSearch: true, getValue( { item }: { item: DataViewThreat } ) { - return item.fixable ? 'Yes' : ''; - }, - render( { item }: { item: DataViewThreat } ) { - return item.fixable ? : null; + return item.signature || ''; }, } ); } - if ( dataFields.includes( 'severity' ) ) { + if ( dataFields.includes( 'fixable' ) ) { result.push( { - id: 'severity', - label: __( 'Severity', 'jetpack' ), + id: 'auto-fix', + label: __( 'Auto-fix', 'jetpack' ), + enableHiding: false, getValue( { item }: { item: DataViewThreat } ) { - return item.severity ?? 0; + return item.fixable ? 'Yes' : ''; }, render( { item }: { item: DataViewThreat } ) { - return ; + return item.fixable ? : null; }, } ); } - if ( dataFields.includes( 'signature' ) ) { - result.push( { - id: 'signature', - label: __( 'Signature', 'jetpack' ), - elements: signatures, - enableGlobalSearch: true, - } ); - } - return result; - }, [ extensions, signatures, dataFields ] ); + }, [ extensions, signatures, dataFields, view ] ); /** * DataView actions - collection of operations that can be performed upon each record. @@ -304,15 +404,6 @@ export default function ThreatsDataView( { isThreatEligibleForUnignore, ] ); - /** - * DataView default layouts. - * - * This property provides layout information about the view types that are active. If empty, enables all layout types (see “Layout Types”) with empty layout data. - * - * @see https://developer.wordpress.org/block-editor/reference-guides/packages/packages-dataviews/#defaultlayouts-record-string-view - */ - const defaultLayouts: SupportedLayouts = {}; - /** * Apply the view settings (i.e. filters, sorting, pagination) to the dataset. * diff --git a/projects/js-packages/components/components/threats-data-view/styles.module.scss b/projects/js-packages/components/components/threats-data-view/styles.module.scss index 53abe3bc19fac..f514629759f0a 100644 --- a/projects/js-packages/components/components/threats-data-view/styles.module.scss +++ b/projects/js-packages/components/components/threats-data-view/styles.module.scss @@ -4,6 +4,35 @@ .dataviews-view-table td, .dataviews-view-table th { white-space: initial; } + + .dataviews-view-list .dataviews-views-list__fields { + align-items: center; + } +} + +.media { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + color: black; + background-color: #EDFFEE; + border-color: #EDFFEE; + + svg { + fill: currentColor; + } +} + +.media--critical { + background-color: var( --jp-red-5 ); + color: var( --jp-red-80 ); +} + +.media--high { + background-color: var( --jp-yellow-5 ); + color: var( --jp-yellow-60 ); } .icon-check { @@ -24,12 +53,41 @@ } } +.fixer-status-badge { + border-radius: 32px; + flex-shrink: 0; + font-size: 12px; + font-style: normal; + font-weight: 600; + line-height: 16px; + padding: calc( var( --spacing-base ) / 2 ); // 4px + padding-right: calc( var( --spacing-base ) * 1.75 ); // 14px + position: relative; + text-align: center; + background: var( --jp-green-0 ); + color: var( --jp-green-50 ); + display: flex; + align-items: center; + + > svg { + height: 20px; + margin-top: -2px; + margin-bottom: -2px; + } +} + +.threat__primary { + display: flex; + align-items: center; + gap: 8px; +} + .threat__subtitle { display: flex; align-items: center; gap: 6px; font-size: 0.75rem; // 12px - line-height: 1.25rem; // 20px + line-height: 1; color: var( --jp-gray-80 ); font-weight: 400; } @@ -43,3 +101,11 @@ .threat__description { color: var( --jp-gray-80 ); } + +.threat__severityHigh { + color: var( --jp-yellow-60 ); +} + +.threat__severityCritical { + color: var( --jp-red-60 ); +}