From d2d28f265f7f3514ac6698629b04db7b19b41040 Mon Sep 17 00:00:00 2001 From: Nate Weller Date: Wed, 18 Dec 2024 17:57:57 -0700 Subject: [PATCH] ThreatsDataViews: Introduce controlled fields --- .../threats-data-views/constants.ts | 37 +- .../components/threats-data-views/index.tsx | 379 ++-------------- .../stories/index.stories.tsx | 2 + .../threats-data-views/styles.module.scss | 4 + .../threats-status-toggle-group-control.tsx | 45 +- .../use-controlled-fields.tsx | 414 ++++++++++++++++++ .../components/threats-data-views/utils.ts | 9 + 7 files changed, 516 insertions(+), 374 deletions(-) create mode 100644 projects/js-packages/components/components/threats-data-views/use-controlled-fields.tsx create mode 100644 projects/js-packages/components/components/threats-data-views/utils.ts diff --git a/projects/js-packages/components/components/threats-data-views/constants.ts b/projects/js-packages/components/components/threats-data-views/constants.ts index aac25cceb3243..41e69f8be9558 100644 --- a/projects/js-packages/components/components/threats-data-views/constants.ts +++ b/projects/js-packages/components/components/threats-data-views/constants.ts @@ -30,7 +30,6 @@ export const THREAT_ICONS = { default: shieldIcon, }; -export const THREAT_FIELD_THREAT = 'threat'; export const THREAT_FIELD_TITLE = 'title'; export const THREAT_FIELD_DESCRIPTION = 'description'; export const THREAT_FIELD_ICON = 'icon'; @@ -45,22 +44,38 @@ export const THREAT_FIELD_FIRST_DETECTED = 'first-detected'; export const THREAT_FIELD_FIXED_ON = 'fixed-on'; export const THREAT_FIELD_AUTO_FIX = 'auto-fix'; -export const DEFAULT_TABLE_FIELDS = [ - THREAT_FIELD_SEVERITY, - THREAT_FIELD_THREAT, +/** + * DataViews fields relevant to vulnerable extension threats. + * Example: Threat results generated by the free Protect Report feature. + */ +export const VULNERABILITY_FIELDS = [ + THREAT_FIELD_TITLE, + THREAT_FIELD_DESCRIPTION, + THREAT_FIELD_ICON, THREAT_FIELD_TYPE, - THREAT_FIELD_FIRST_DETECTED, - THREAT_FIELD_FIXED_ON, - THREAT_FIELD_STATUS, - THREAT_FIELD_AUTO_FIX, + THREAT_FIELD_EXTENSION, + THREAT_FIELD_PLUGIN, + THREAT_FIELD_THEME, ]; -export const DEFAULT_LIST_FIELDS = [ - THREAT_FIELD_SEVERITY, +/** + * DataViews fields related to threats. + * Example: Threat results detected by Jetpack Scan. + */ +export const THREAT_FIELDS = [ + THREAT_FIELD_TITLE, + THREAT_FIELD_DESCRIPTION, + THREAT_FIELD_ICON, + THREAT_FIELD_STATUS, THREAT_FIELD_TYPE, THREAT_FIELD_EXTENSION, + THREAT_FIELD_PLUGIN, + THREAT_FIELD_THEME, + THREAT_FIELD_SEVERITY, THREAT_FIELD_SIGNATURE, - THREAT_FIELD_STATUS, + THREAT_FIELD_FIRST_DETECTED, + THREAT_FIELD_FIXED_ON, + THREAT_FIELD_AUTO_FIX, ]; export const THREAT_ACTION_FIX = 'fix'; 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 908af8e1674ac..696251797f76e 100644 --- a/projects/js-packages/components/components/threats-data-views/index.tsx +++ b/projects/js-packages/components/components/threats-data-views/index.tsx @@ -1,9 +1,7 @@ -import { getThreatType, getFixerAction, type Threat } from '@automattic/jetpack-scan'; +import { getFixerAction, type Threat } from '@automattic/jetpack-scan'; import { type Action, type ActionButton, - type Field, - type FieldType, type Filter, type SortDirection, type SupportedLayouts, @@ -11,16 +9,9 @@ import { DataViews, filterSortAndPaginate, } from '@wordpress/dataviews'; -import { dateI18n } from '@wordpress/date'; import { __ } from '@wordpress/i18n'; -import { Icon } from '@wordpress/icons'; import { useCallback, useMemo, useState } from 'react'; -import Badge from '../badge'; -import ThreatFixerButton from '../threat-fixer-button'; -import ThreatSeverityBadge from '../threat-severity-badge'; import { - DEFAULT_TABLE_FIELDS, - DEFAULT_LIST_FIELDS, THREAT_ACTION_FIX, THREAT_ACTION_IGNORE, THREAT_ACTION_UNIGNORE, @@ -28,21 +19,14 @@ import { THREAT_FIELD_DESCRIPTION, THREAT_FIELD_EXTENSION, THREAT_FIELD_FIRST_DETECTED, - THREAT_FIELD_FIXED_ON, THREAT_FIELD_ICON, - THREAT_FIELD_PLUGIN, THREAT_FIELD_SEVERITY, THREAT_FIELD_SIGNATURE, - THREAT_FIELD_STATUS, - THREAT_FIELD_THEME, THREAT_FIELD_TITLE, THREAT_FIELD_TYPE, - THREAT_ICONS, - THREAT_STATUSES, - THREAT_TYPES, } from './constants'; -import styles from './styles.module.scss'; import ThreatsStatusToggleGroupControl from './threats-status-toggle-group-control'; +import useControlledFields from './use-controlled-fields'; /** * DataViews component for displaying security threats. @@ -50,6 +34,7 @@ import ThreatsStatusToggleGroupControl from './threats-status-toggle-group-contr * @param {object} props - Component props. * @param {Array} props.data - Threats data. * @param {Array} props.filters - Initial DataView filters. + * @param {Array} props.supportedFields - Supported fields for the DataView. * @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. @@ -64,6 +49,7 @@ export default function ThreatsDataViews( { data, filters, onChangeSelection, + supportedFields, isThreatEligibleForFix, isThreatEligibleForIgnore, isThreatEligibleForUnignore, @@ -74,6 +60,7 @@ export default function ThreatsDataViews( { data: Threat[]; filters?: Filter[]; onChangeSelection?: ( selectedItemIds: string[] ) => void; + supportedFields: string[]; isThreatEligibleForFix?: ( threat: Threat ) => boolean; isThreatEligibleForIgnore?: ( threat: Threat ) => boolean; isThreatEligibleForUnignore?: ( threat: Threat ) => boolean; @@ -102,14 +89,19 @@ export default function ThreatsDataViews( { const defaultLayouts: SupportedLayouts = { table: { ...baseView, - fields: DEFAULT_TABLE_FIELDS, + fields: [ THREAT_FIELD_SEVERITY, THREAT_FIELD_FIRST_DETECTED, THREAT_FIELD_AUTO_FIX ], titleField: THREAT_FIELD_TITLE, descriptionField: THREAT_FIELD_DESCRIPTION, showMedia: false, }, list: { ...baseView, - fields: DEFAULT_LIST_FIELDS, + fields: [ + THREAT_FIELD_SEVERITY, + THREAT_FIELD_TYPE, + THREAT_FIELD_EXTENSION, + THREAT_FIELD_SIGNATURE, + ], titleField: THREAT_FIELD_TITLE, mediaField: THREAT_FIELD_ICON, showMedia: true, @@ -126,318 +118,21 @@ export default function ThreatsDataViews( { ...defaultLayouts.table, } ); - /** - * Compute the status filters from the view filters. - */ - const statusFilters = useMemo( - () => - view.filters.reduce( ( acc, filter ) => { - if ( filter.field === THREAT_FIELD_STATUS ) { - if ( Array.isArray( filter.value ) ) { - for ( const value of filter.value ) { - if ( ! acc.includes( value ) ) { - acc.push( value ); - } - } - } else if ( ! acc.includes( filter.value ) ) { - acc.push( filter.value ); - } - } - return acc; - }, [] ), - [ view.filters ] - ); - - /** - * Compute values from the provided 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. - */ - const { - themes, - plugins, - signatures, - dataFields, - }: { - themes: { value: string; label: string }[]; - plugins: { value: string; label: string }[]; - signatures: { value: string; label: string }[]; - dataFields: string[]; - } = useMemo( () => { - return data.reduce( - ( acc, threat ) => { - // Extensions (Themes and Plugins) - if ( threat.extension ) { - switch ( threat.extension.type ) { - case 'themes': - if ( ! acc.themes.find( ( { value } ) => value === threat.extension.slug ) ) { - acc.themes.push( { value: threat.extension.slug, label: threat.extension.name } ); - } - break; - case 'plugins': - if ( ! acc.plugins.find( ( { value } ) => value === threat.extension.slug ) ) { - acc.plugins.push( { value: threat.extension.slug, label: threat.extension.name } ); - } - break; - default: - break; - } - } - - // Signatures - if ( threat.signature ) { - if ( ! acc.signatures.find( ( { value } ) => value === threat.signature ) ) { - acc.signatures.push( { value: threat.signature, label: threat.signature } ); - } - } - - // Fields - const fields = Object.keys( threat ); - fields.forEach( field => { - if ( - ! acc.dataFields.includes( field ) && - threat[ field ] !== null && - threat[ field ] !== undefined - ) { - acc.dataFields.push( field ); - } - } ); - - return acc; - }, - { - themes: [], - plugins: [], - signatures: [], - dataFields: [], - } - ); - }, [ data ] ); - - /** - * DataView fields - describes the visible items for each record in the dataset. - * - * @see https://developer.wordpress.org/block-editor/reference-guides/packages/packages-dataviews/#fields-object - */ - const fields = useMemo( () => { - const result: Field< Threat >[] = [ - { - id: THREAT_FIELD_TITLE, - label: __( 'Threat', 'jetpack-components' ), - enableGlobalSearch: true, - enableHiding: false, - render: ( { item }: { item: Threat } ) => ( -
{ item.title }
- ), - }, - { - id: THREAT_FIELD_DESCRIPTION, - label: __( 'Description', 'jetpack-components' ), - enableGlobalSearch: true, - enableHiding: false, - render: ( { item }: { item: Threat } ) => ( -
{ item.description }
- ), - }, - { - id: THREAT_FIELD_ICON, - label: __( 'Icon', 'jetpack-components' ), - enableHiding: false, - getValue( { item }: { item: Threat } ) { - return getThreatType( item ); - }, - render( { item }: { item: Threat } ) { - return ( -
- -
- ); - }, - }, - { - id: THREAT_FIELD_TYPE, - label: __( 'Type', 'jetpack-components' ), - enableHiding: false, - elements: THREAT_TYPES, - getValue( { item }: { item: Threat } ) { - return getThreatType( item ) ?? ''; - }, - }, - { - id: THREAT_FIELD_EXTENSION, - label: __( 'Extension', 'jetpack-components' ), - enableGlobalSearch: true, - enableHiding: true, - getValue( { item }: { item: Threat } ) { - return item.extension ? item.extension.slug : ''; - }, - render( { item }: { item: Threat } ) { - return item.extension ? item.extension.name : ''; - }, - }, - { - id: THREAT_FIELD_PLUGIN, - label: __( 'Plugin', 'jetpack-components' ), - enableGlobalSearch: true, - enableHiding: false, - elements: plugins, - getValue( { item }: { item: Threat } ) { - return item.extension ? item.extension.slug : ''; - }, - }, - { - id: THREAT_FIELD_THEME, - label: __( 'Theme', 'jetpack-components' ), - enableGlobalSearch: true, - enableHiding: false, - elements: themes, - getValue( { item }: { item: Threat } ) { - return item.extension ? item.extension.slug : ''; - }, - }, - ...( dataFields.includes( 'status' ) - ? [ - { - id: THREAT_FIELD_STATUS, - label: __( 'Status', 'jetpack-components' ), - enableHiding: false, - elements: THREAT_STATUSES, - getValue( { item }: { item: Threat } ) { - if ( ! item.status ) { - return 'current'; - } - return ( - THREAT_STATUSES.find( ( { value } ) => value === item.status )?.value ?? - item.status - ); - }, - render( { item }: { item: Threat } ) { - if ( item.status ) { - const status = THREAT_STATUSES.find( ( { value } ) => value === item.status ); - if ( status ) { - return { status.label }; - } - } - return { __( 'Active', 'jetpack-components' ) }; - }, - }, - ] - : [] ), - ...( dataFields.includes( 'severity' ) - ? [ - { - id: THREAT_FIELD_SEVERITY, - label: __( 'Severity', 'jetpack-components' ), - type: 'integer' as FieldType, - getValue( { item }: { item: Threat } ) { - return item.severity ?? 0; - }, - render( { item }: { item: Threat } ) { - return ; - }, - }, - ] - : [] ), - ...( dataFields.includes( 'signature' ) - ? [ - { - id: THREAT_FIELD_SIGNATURE, - label: __( 'Signature', 'jetpack-components' ), - elements: signatures, - enableGlobalSearch: true, - getValue( { item }: { item: Threat } ) { - return item.signature || ''; - }, - }, - ] - : [] ), - ...( dataFields.includes( 'firstDetected' ) && - ( statusFilters.includes( 'fixed' ) || statusFilters.includes( 'ignored' ) ) - ? [ - { - id: THREAT_FIELD_FIRST_DETECTED, - label: __( 'First Detected', 'jetpack-components' ), - type: 'datetime' as FieldType, - getValue( { item }: { item: Threat } ) { - return item.firstDetected ? new Date( item.firstDetected ) : null; - }, - render( { item }: { item: Threat } ) { - return item.firstDetected ? ( - - { dateI18n( 'F j Y', item.firstDetected, false ) } - - ) : null; - }, - }, - ] - : [] ), - ...( dataFields.includes( 'fixedOn' ) && statusFilters.includes( 'fixed' ) - ? [ - { - id: THREAT_FIELD_FIXED_ON, - label: __( 'Fixed On', 'jetpack-components' ), - type: 'datetime' as FieldType, - getValue( { item }: { item: Threat } ) { - return item.fixedOn ? new Date( item.fixedOn ) : null; - }, - render( { item }: { item: Threat } ) { - return item.fixedOn ? ( - - { dateI18n( 'F j Y', item.fixedOn, false ) } - - ) : null; - }, - }, - ] - : [] ), - ...( dataFields.includes( 'fixable' ) && - ( statusFilters.includes( 'current' ) || ! statusFilters.length ) - ? [ - { - id: THREAT_FIELD_AUTO_FIX, - label: __( 'Auto-fix', 'jetpack-components' ), - enableHiding: false, - elements: [ - { - value: 'yes', - label: __( 'Yes', 'jetpack-components' ), - }, - { - value: 'no', - label: __( 'No', 'jetpack-components' ), - }, - ], - getValue( { item }: { item: Threat } ) { - return item.fixable ? 'yes' : 'no'; - }, - render( { item }: { item: Threat } ) { - if ( ! item.fixable ) { - return null; - } - - return ; - }, - }, - ] - : [] ), - ]; - - return result; - }, [ dataFields, plugins, themes, signatures, onFixThreats, statusFilters ] ); + const { fields, controlFields } = useControlledFields( { + data, + view, + supportedFields, + onFixThreats, + } ); /** * DataView actions - collection of operations that can be performed upon each record. * * @see https://developer.wordpress.org/block-editor/reference-guides/packages/packages-dataviews/#actions-object */ - const actions = useMemo( () => { - const result: Action< Threat >[] = []; - - if ( dataFields.includes( 'fixable' ) && view.type === 'list' ) { - result.push( { + const actions: Action< Threat >[] = useMemo( () => { + return [ + { id: THREAT_ACTION_FIX, label: items => { return getFixerAction( items[ 0 ] ); @@ -445,6 +140,9 @@ export default function ThreatsDataViews( { isPrimary: true, callback: onFixThreats, isEligible( item ) { + if ( view.type !== 'list' ) { + return false; + } if ( ! onFixThreats ) { return false; } @@ -453,11 +151,8 @@ export default function ThreatsDataViews( { } return !! item.fixable; }, - } ); - } - - if ( dataFields.includes( 'status' ) ) { - result.push( { + }, + { id: THREAT_ACTION_IGNORE, label: __( 'Ignore', 'jetpack-components' ), isPrimary: true, @@ -472,11 +167,8 @@ export default function ThreatsDataViews( { } return item.status === 'current'; }, - } ); - } - - if ( dataFields.includes( 'status' ) ) { - result.push( { + }, + { id: THREAT_ACTION_UNIGNORE, label: __( 'Unignore', 'jetpack-components' ), isPrimary: true, @@ -491,13 +183,10 @@ export default function ThreatsDataViews( { } return item.status === 'ignored'; }, - } ); - } - - return result; + }, + ]; }, [ view.type, - dataFields, onFixThreats, onIgnoreThreats, onUnignoreThreats, @@ -520,9 +209,10 @@ export default function ThreatsDataViews( { * * @see https://developer.wordpress.org/block-editor/reference-guides/packages/packages-dataviews/#onchangeview-function */ - const onChangeView = useCallback( ( newView: View ) => { - setView( newView ); - }, [] ); + const onChangeView = useCallback( + ( newView: View ) => setView( currentView => controlFields( currentView, newView ) ), + [ controlFields ] + ); /** * DataView getItemId function - returns the unique ID for each record in the dataset. @@ -546,7 +236,6 @@ export default function ThreatsDataViews( { } 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 4839415084b80..7c41cd7fc8770 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 @@ -1,4 +1,5 @@ import ThreatsDataViews from '..'; +import { VULNERABILITY_FIELDS } from '../constants'; export default { title: 'JS Packages/Components/Threats Data Views', @@ -325,4 +326,5 @@ FreeResults.args = { }, }, ], + supportedFields: VULNERABILITY_FIELDS, }; 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 7d97f69b25ccd..549290318939a 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 @@ -4,6 +4,10 @@ color: var( --jp-gray-80 ); font-weight: 510; white-space: initial; + + display: flex; + align-items: center; + gap: calc( var( --spacing-base ) * 0.666 ); } .threat__description { 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 index dc0b8ffb3ed70..2472382987271 100644 --- 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 @@ -1,4 +1,4 @@ -import { type Threat, type ThreatStatus } from '@automattic/jetpack-scan'; +import { ThreatStatus, type Threat } 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 @@ -10,23 +10,20 @@ 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 { ThreatStatus[] } props.statusFilters - The current status filter value. - * @param { Function } props.onChangeView - Callback function to handle view changes. + * @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, - statusFilters, onChangeView, }: { data: Threat[]; view: View; - statusFilters: ThreatStatus[]; onChangeView: ( newView: View ) => void; } ): JSX.Element { /** @@ -92,19 +89,31 @@ export default function ThreatsStatusToggleGroupControl( { ); /** - * Get the selected status filters. + * Memoized function to determine if a status filter is selected. * - * @return {string|null} The selected status filter. + * @param {Array} threatStatuses - List of threat statuses. */ - const selectedStatusFilterPreset = useMemo( () => { - if ( JSON.stringify( statusFilters ) === JSON.stringify( [ 'current' ] ) ) { - return 'active'; + 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 ( JSON.stringify( statusFilters ) === JSON.stringify( [ 'fixed', 'ignored' ] ) ) { - return 'historic'; + if ( isStatusFilterSelected( [ 'fixed', 'ignored' ] ) ) { + return 'historic' as const; } - return null; - }, [ statusFilters ] ); + return '' as const; + }, [ isStatusFilterSelected ] ); if ( ! ( activeThreatsCount + historicThreatsCount ) ) { return null; @@ -115,7 +124,7 @@ export default function ThreatsStatusToggleGroupControl( {
( [] ); + + /** + * Compute values from the provided 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. + */ + const { + themes, + plugins, + signatures, + }: { + themes: { value: string; label: string }[]; + plugins: { value: string; label: string }[]; + signatures: { value: string; label: string }[]; + } = useMemo( () => { + return data.reduce( + ( acc, threat ) => { + // Extensions (Themes and Plugins) + if ( threat.extension ) { + switch ( threat.extension.type ) { + case 'themes': + if ( ! acc.themes.find( ( { value } ) => value === threat.extension.slug ) ) { + acc.themes.push( { value: threat.extension.slug, label: threat.extension.name } ); + } + break; + case 'plugins': + if ( ! acc.plugins.find( ( { value } ) => value === threat.extension.slug ) ) { + acc.plugins.push( { value: threat.extension.slug, label: threat.extension.name } ); + } + break; + default: + break; + } + } + + // Signatures + if ( threat.signature ) { + if ( ! acc.signatures.find( ( { value } ) => value === threat.signature ) ) { + acc.signatures.push( { value: threat.signature, label: threat.signature } ); + } + } + + return acc; + }, + { + themes: [], + plugins: [], + signatures: [], + } + ); + }, [ data ] ); + + /** + * DataView fields - describes the visible items for each record in the dataset. + * + * @see https://developer.wordpress.org/block-editor/reference-guides/packages/packages-dataviews/#fields-object + */ + const fields: Array< + Field< Threat > & { + isDefault?: ( v: View ) => boolean; + insertAfter?: string; + views?: string[]; + } + > = useMemo( () => { + return [ + { + id: THREAT_FIELD_TITLE, + label: __( 'Threat', 'jetpack-components' ), + enableGlobalSearch: true, + enableHiding: false, + render: ( { item }: { item: Threat } ) => ( +
+ { view.type === 'table' && ( + + ) } + { item.title } +
+ ), + }, + { + id: THREAT_FIELD_DESCRIPTION, + label: __( 'Description', 'jetpack-components' ), + enableGlobalSearch: true, + enableHiding: false, + render: ( { item }: { item: Threat } ) => ( +
{ item.description }
+ ), + }, + { + id: THREAT_FIELD_ICON, + label: __( 'Icon', 'jetpack-components' ), + views: [ 'list' ], + getValue( { item }: { item: Threat } ) { + return getThreatType( item ); + }, + render( { item }: { item: Threat } ) { + return ( +
+ +
+ ); + }, + }, + { + id: THREAT_FIELD_STATUS, + label: __( 'Status', 'jetpack-components' ), + elements: THREAT_STATUSES, + enableHiding: ( () => { + const statusFilters = getFilterValues( view, THREAT_FIELD_STATUS ); + return ! ( + ! statusFilters.length || + statusFilters.includes( 'fixed' ) || + statusFilters.includes( 'ignored' ) + ); + } )(), + insertAfter: THREAT_FIELD_SEVERITY, + getValue( { item }: { item: Threat } ) { + if ( ! item.status ) { + return 'current'; + } + return ( + THREAT_STATUSES.find( ( { value } ) => value === item.status )?.value ?? item.status + ); + }, + render( { item }: { item: Threat } ) { + if ( item.status ) { + const status = THREAT_STATUSES.find( ( { value } ) => value === item.status ); + if ( status ) { + return { status.label }; + } + } + return { __( 'Active', 'jetpack-components' ) }; + }, + isDefault: ( v: View ) => { + const statusFilters = getFilterValues( v, THREAT_FIELD_STATUS ); + return ( + ! statusFilters.length || + statusFilters.includes( 'fixed' ) || + statusFilters.includes( 'ignored' ) + ); + }, + }, + { + id: THREAT_FIELD_SEVERITY, + label: __( 'Severity', 'jetpack-components' ), + type: 'integer' as FieldType, + getValue( { item }: { item: Threat } ) { + return item.severity ?? 0; + }, + render( { item }: { item: Threat } ) { + return ; + }, + }, + { + id: THREAT_FIELD_TYPE, + label: __( 'Type', 'jetpack-components' ), + elements: THREAT_TYPES, + getValue( { item }: { item: Threat } ) { + return getThreatType( item ) ?? ''; + }, + }, + { + id: THREAT_FIELD_PLUGIN, + label: __( 'Plugin', 'jetpack-components' ), + enableGlobalSearch: true, + enableHiding: false, + elements: plugins, + getValue( { item }: { item: Threat } ) { + return item.extension ? item.extension.slug : ''; + }, + render( { item }: { item: Threat } ) { + return item.extension ? item.extension.name : ''; + }, + }, + { + id: THREAT_FIELD_THEME, + label: __( 'Theme', 'jetpack-components' ), + enableGlobalSearch: true, + enableHiding: false, + elements: themes, + getValue( { item }: { item: Threat } ) { + return item.extension ? item.extension.slug : ''; + }, + render( { item }: { item: Threat } ) { + return item.extension ? item.extension.name : ''; + }, + }, + { + id: THREAT_FIELD_EXTENSION, + label: __( 'Extension', 'jetpack-components' ), + enableGlobalSearch: true, + getValue( { item }: { item: Threat } ) { + return item.extension ? item.extension.slug : ''; + }, + render( { item }: { item: Threat } ) { + return item.extension ? item.extension.name : ''; + }, + }, + { + id: THREAT_FIELD_SIGNATURE, + label: __( 'Signature', 'jetpack-components' ), + elements: signatures, + enableGlobalSearch: true, + getValue( { item }: { item: Threat } ) { + return item.signature || ''; + }, + }, + { + id: THREAT_FIELD_FIRST_DETECTED, + label: __( 'First Detected', 'jetpack-components' ), + type: 'datetime' as FieldType, + getValue( { item }: { item: Threat } ) { + return item.firstDetected ? new Date( item.firstDetected ) : null; + }, + render( { item }: { item: Threat } ) { + return item.firstDetected ? ( + + { dateI18n( 'F j Y', item.firstDetected, false ) } + + ) : null; + }, + }, + { + id: THREAT_FIELD_FIXED_ON, + label: __( 'Fixed On', 'jetpack-components' ), + type: 'datetime' as FieldType, + enableHiding: ! getFilterValues( view, THREAT_FIELD_STATUS ).includes( 'fixed' ), + insertAfter: THREAT_FIELD_FIRST_DETECTED, + getValue( { item }: { item: Threat } ) { + return item.fixedOn ? new Date( item.fixedOn ) : null; + }, + render( { item }: { item: Threat } ) { + return item.fixedOn ? ( + + { dateI18n( 'F j Y', item.fixedOn, false ) } + + ) : null; + }, + isDefault: ( v: View ) => { + const statusFilters = getFilterValues( v, THREAT_FIELD_STATUS ); + return statusFilters.includes( 'fixed' ); + }, + }, + { + id: THREAT_FIELD_AUTO_FIX, + label: __( 'Auto-fix', 'jetpack-components' ), + enableHiding: false, + elements: [ + { + value: 'yes', + label: __( 'Yes', 'jetpack-components' ), + }, + { + value: 'no', + label: __( 'No', 'jetpack-components' ), + }, + ], + views: [ 'table' ], + getValue( { item }: { item: Threat } ) { + return item.fixable ? 'yes' : 'no'; + }, + render( { item }: { item: Threat } ) { + if ( ! item.fixable ) { + return null; + } + + return ; + }, + isDefault: ( v: View ) => { + const statusFilters = getFilterValues( v, THREAT_FIELD_STATUS ); + return ! statusFilters.length || statusFilters.includes( 'current' ); + }, + }, + ].filter( field => { + if ( supportedFields ) { + return supportedFields.includes( field.id ); + } + + if ( field.views && ! field.views.includes( view.type ) ) { + return false; + } + + return true; + } ); + }, [ onFixThreats, plugins, signatures, themes, view, supportedFields ] ); + + /** + * Control Fields - Manages the visibility of fields based on the changing view configuration. + */ + const controlFields = useCallback( + ( oldView: View, newView: View ): View => { + const customView = { ...newView }; + const customFields = [ ...newView.fields ]; + + for ( const field of fields ) { + /** @member {bool} wasDefault - True when the field should be shown by default based on the current view config. */ + const wasDefault = field.isDefault ? field.isDefault( oldView ) : false; + + /** @member {bool} newIsDefault - True when the field should be shown by default based on the incoming view config. */ + const isDefault = field.isDefault ? field.isDefault( newView ) : false; + + /** @member {bool} newIsIncluded - True when the field is present in the incoming view config. */ + const isIncluded = customFields.includes( field.id ); + + /** @member {bool} wasIncluded - True when the field is present in the incoming view config. */ + const wasIncluded = oldView.fields.includes( field.id ); + + /** @member {bool} newIsForced - True when the field as been manually included. */ + let isForced = forceShowFields.includes( field.id ); + + // Adding a non-default field + if ( isIncluded && ! isDefault && ! wasDefault && ! isForced ) { + isForced = true; + setForceShowFields( currentFields => { + return [ ...currentFields, field.id ]; + } ); + + // Enforce the order of the fields + if ( field.insertAfter ) { + const fromIndex = customView.fields.indexOf( field.id ); + const toIndex = customFields.indexOf( field.insertAfter ); + if ( fromIndex !== -1 && toIndex !== -1 ) { + const element = customView.fields[ fromIndex ]; + customView.fields.splice( fromIndex, 1 ); + customView.fields.splice( toIndex + 1, 0, element ); + } + } + } + + // Removing a non-default field + if ( ! isIncluded && wasIncluded && ! isDefault && isForced ) { + isForced = false; + setForceShowFields( currentFields => { + return currentFields.filter( f => f !== field.id ); + } ); + } + + // Remove the field if it should no longer be visible. + if ( isIncluded && ! isDefault && ! isForced ) { + customView.fields = customView.fields.filter( f => f !== field.id ); + } + + // Insert the field if it should be visible. + if ( ! isIncluded && ( isDefault || isForced ) ) { + // If specified, insert the field after another... + if ( field.insertAfter ) { + const index = customView.fields.indexOf( field.insertAfter ); + if ( index !== -1 ) { + customView.fields.splice( index + 1, 0, field.id ); + continue; + } + } + + // ...otherwise, just add it to the end. + customView.fields.push( field.id ); + } + } + + return customView; + }, + [ fields, forceShowFields ] + ); + + return { + fields, + controlFields, + }; +} diff --git a/projects/js-packages/components/components/threats-data-views/utils.ts b/projects/js-packages/components/components/threats-data-views/utils.ts new file mode 100644 index 0000000000000..b033c78e46043 --- /dev/null +++ b/projects/js-packages/components/components/threats-data-views/utils.ts @@ -0,0 +1,9 @@ +import { View } from '@wordpress/dataviews'; + +export const getFilterValues = ( currentView: View, field: string ) => { + const filter = currentView.filters.find( f => f.field === field ); + if ( ! filter ) { + return []; + } + return Array.isArray( filter.value ) ? filter.value : [ filter.value ]; +};