@automattic/jetpack-scan 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/package.json +22 -25
  3. package/src/components/threat-severity-badge/index.tsx +8 -4
  4. package/src/components/threats-data-views/constants.ts +6 -6
  5. package/src/components/threats-data-views/index.tsx +243 -84
  6. package/src/components/threats-data-views/styles.module.scss +23 -0
  7. package/build/components/index.d.ts +0 -4
  8. package/build/components/index.js +0 -4
  9. package/build/components/threat-fixer-button/index.d.ts +0 -16
  10. package/build/components/threat-fixer-button/index.js +0 -56
  11. package/build/components/threat-modal/fixer-state-notice.d.ts +0 -19
  12. package/build/components/threat-modal/fixer-state-notice.js +0 -44
  13. package/build/components/threat-modal/index.d.ts +0 -52
  14. package/build/components/threat-modal/index.js +0 -49
  15. package/build/components/threat-modal/threat-actions.d.ts +0 -7
  16. package/build/components/threat-modal/threat-actions.js +0 -43
  17. package/build/components/threat-modal/threat-fix-confirmation.d.ts +0 -7
  18. package/build/components/threat-modal/threat-fix-confirmation.js +0 -21
  19. package/build/components/threat-modal/threat-fix-details.d.ts +0 -7
  20. package/build/components/threat-modal/threat-fix-details.js +0 -41
  21. package/build/components/threat-modal/threat-notice.d.ts +0 -16
  22. package/build/components/threat-modal/threat-notice.js +0 -26
  23. package/build/components/threat-modal/threat-summary.d.ts +0 -7
  24. package/build/components/threat-modal/threat-summary.js +0 -16
  25. package/build/components/threat-modal/threat-technical-details.d.ts +0 -7
  26. package/build/components/threat-modal/threat-technical-details.js +0 -31
  27. package/build/components/threat-severity-badge/index.d.ts +0 -4
  28. package/build/components/threat-severity-badge/index.js +0 -13
  29. package/build/components/threat-severity-badge/test/index.test.d.ts +0 -1
  30. package/build/components/threat-severity-badge/test/index.test.js +0 -9
  31. package/build/components/threats-data-views/constants.d.ts +0 -42
  32. package/build/components/threats-data-views/constants.js +0 -37
  33. package/build/components/threats-data-views/index.d.ts +0 -29
  34. package/build/components/threats-data-views/index.js +0 -410
  35. package/build/components/threats-data-views/test/index.test.d.ts +0 -1
  36. package/build/components/threats-data-views/test/index.test.js +0 -51
  37. package/build/components/threats-data-views/threats-status-toggle-group-control.d.ts +0 -15
  38. package/build/components/threats-data-views/threats-status-toggle-group-control.js +0 -97
  39. package/build/constants/index.d.ts +0 -3
  40. package/build/constants/index.js +0 -3
  41. package/build/index.d.ts +0 -4
  42. package/build/index.js +0 -4
  43. package/build/index.test.d.ts +0 -1
  44. package/build/index.test.js +0 -4
  45. package/build/types/fixers.d.ts +0 -34
  46. package/build/types/fixers.js +0 -1
  47. package/build/types/index.d.ts +0 -3
  48. package/build/types/index.js +0 -3
  49. package/build/types/status.d.ts +0 -42
  50. package/build/types/status.js +0 -1
  51. package/build/types/threats.d.ts +0 -50
  52. package/build/types/threats.js +0 -1
  53. package/build/utils/index.d.ts +0 -15
  54. package/build/utils/index.js +0 -125
package/CHANGELOG.md CHANGED
@@ -5,6 +5,26 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.4.0] - 2026-05-11
9
+ ### Added
10
+ - ThreatsDataViews: Add an optional `empty` prop that's forwarded to the underlying `DataViews` so consumers can render their own empty-state node instead of DataViews' built-in "no items" body. [#48458]
11
+ - ThreatsDataViews: Accept `RenderFixModal`, `RenderIgnoreModal`, and `RenderUnignoreModal` props so consumers can route row actions through DataViews-managed confirmation modals. [#48458]
12
+ - ThreatsDataViews: Add an optional `onTrackEvent` callback for DataViews-canonical view transition events. [#48458]
13
+ - ThreatsDataViews: Add an optional `persistKey` prop to persist filters, sort, search, pagination, and layout in local storage. [#48458]
14
+ - ThreatsDataViews: Add an optional `RenderViewModal` prop for always-available threat detail views. [#48458]
15
+ - ThreatsDataViews: Add an optional `showStatusFilter` prop so consumers that already filter by status can hide the in-table active/history toggle. [#48458]
16
+
17
+ ### Changed
18
+ - Badge: Migrated usages to @wordpress/ui Badge. [#48156]
19
+ - Internal: No longer require automattic/jetpack-changelogger as a per-project dev dependency. [#48225]
20
+ - Scan Package: Switch the package to source exports so consumers resolve `@automattic/jetpack-scan` directly to `./src/index.ts`. [#48458]
21
+ - Update package dependencies. [#47907]
22
+ - Update package dependencies. [#48106]
23
+ - Update package dependencies. [#48126]
24
+ - Update package dependencies. [#48141]
25
+ - Update package dependencies. [#48346]
26
+ - ThreatsDataViews: Anchor the DataViews empty body to `calc(100vh - 320px)` so consumers get a full-height empty state without wiring a custom flex chain. [#48458]
27
+
8
28
  ## [1.3.0] - 2026-04-11
9
29
  ### Changed
10
30
  - Set `.repository.url` in `package.json` to the mirror repo rather than the monorepo. Required for enabling provenance. [#47149]
@@ -175,6 +195,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
175
195
  ### Removed
176
196
  - Updated dependencies. [#39754]
177
197
 
198
+ [1.4.0]: https://github.com/Automattic/jetpack-scan/compare/v1.3.0...v1.4.0
178
199
  [1.3.0]: https://github.com/Automattic/jetpack-scan/compare/v1.2.2...v1.3.0
179
200
  [1.2.2]: https://github.com/Automattic/jetpack-scan/compare/v1.2.1...v1.2.2
180
201
  [1.2.1]: https://github.com/Automattic/jetpack-scan/compare/v1.2.0...v1.2.1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@automattic/jetpack-scan",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "private": false,
5
5
  "description": "A JS client for consuming Jetpack Scan services",
6
6
  "homepage": "https://github.com/Automattic/jetpack/tree/HEAD/projects/js-packages/scan/#readme",
@@ -13,41 +13,38 @@
13
13
  },
14
14
  "license": "GPL-2.0-or-later",
15
15
  "author": "Automattic",
16
+ "sideEffects": [
17
+ "*.css",
18
+ "*.scss"
19
+ ],
16
20
  "type": "module",
17
21
  "exports": {
18
- ".": {
19
- "types": "./build/index.d.ts",
20
- "default": "./build/index.js"
21
- }
22
+ ".": "./src/index.ts"
22
23
  },
23
- "main": "./build/index.js",
24
- "types": "./build/index.d.ts",
25
24
  "scripts": {
26
- "build": "pnpm run clean && pnpm run compile-ts",
27
- "clean": "rm -rf build/",
28
- "compile-ts": "tsgo --pretty",
29
25
  "test": "NODE_OPTIONS=--experimental-vm-modules jest",
30
26
  "test-coverage": "pnpm run test --coverage",
31
27
  "typecheck": "tsgo --noEmit"
32
28
  },
33
29
  "dependencies": {
34
- "@automattic/jetpack-api": "^1.0.22",
35
- "@automattic/jetpack-base-styles": "^1.0.21",
36
- "@automattic/jetpack-components": "^1.8.1",
37
- "@wordpress/api-fetch": "7.43.0",
38
- "@wordpress/components": "32.5.0",
39
- "@wordpress/dataviews": "13.1.0",
40
- "@wordpress/date": "5.43.0",
41
- "@wordpress/element": "6.43.0",
42
- "@wordpress/i18n": "6.16.0",
43
- "@wordpress/icons": "12.0.0",
44
- "@wordpress/theme": "0.10.0",
45
- "@wordpress/url": "4.43.0",
30
+ "@automattic/jetpack-api": "^1.0.24",
31
+ "@automattic/jetpack-base-styles": "^1.2.1",
32
+ "@automattic/jetpack-components": "^1.11.0",
33
+ "@wordpress/api-fetch": "7.44.0",
34
+ "@wordpress/components": "32.6.0",
35
+ "@wordpress/dataviews": "14.1.0",
36
+ "@wordpress/date": "5.44.0",
37
+ "@wordpress/element": "6.44.0",
38
+ "@wordpress/i18n": "6.17.0",
39
+ "@wordpress/icons": "12.2.0",
40
+ "@wordpress/theme": "0.11.0",
41
+ "@wordpress/ui": "0.11.0",
42
+ "@wordpress/url": "4.44.0",
46
43
  "debug": "4.4.3"
47
44
  },
48
45
  "devDependencies": {
49
- "@storybook/addon-docs": "10.3.3",
50
- "@storybook/react": "10.3.3",
46
+ "@storybook/addon-docs": "10.3.5",
47
+ "@storybook/react": "10.3.5",
51
48
  "@testing-library/dom": "10.4.1",
52
49
  "@testing-library/react": "16.3.2",
53
50
  "@types/jest": "30.0.0",
@@ -56,7 +53,7 @@
56
53
  "jest": "30.3.0",
57
54
  "react": "^18.2.0",
58
55
  "react-dom": "^18.2.0",
59
- "storybook": "10.3.3",
56
+ "storybook": "10.3.5",
60
57
  "typescript": "5.9.3"
61
58
  },
62
59
  "peerDependencies": {
@@ -1,10 +1,10 @@
1
- import { Badge } from '@automattic/jetpack-components';
2
1
  import { _x } from '@wordpress/i18n';
2
+ import { Badge } from '@wordpress/ui';
3
3
 
4
4
  const ThreatSeverityBadge = ( { severity } ) => {
5
5
  if ( severity >= 5 ) {
6
6
  return (
7
- <Badge variant="danger">
7
+ <Badge intent="high">
8
8
  { _x( 'Critical', 'Severity label for issues rated 5 or higher.', 'jetpack-scan' ) }
9
9
  </Badge>
10
10
  );
@@ -12,13 +12,17 @@ const ThreatSeverityBadge = ( { severity } ) => {
12
12
 
13
13
  if ( severity >= 3 && severity < 5 ) {
14
14
  return (
15
- <Badge variant="warning">
15
+ <Badge intent="medium">
16
16
  { _x( 'High', 'Severity label for issues rated between 3 and 5.', 'jetpack-scan' ) }
17
17
  </Badge>
18
18
  );
19
19
  }
20
20
 
21
- return <Badge>{ _x( 'Low', 'Severity label for issues rated below 3.', 'jetpack-scan' ) }</Badge>;
21
+ return (
22
+ <Badge intent="draft">
23
+ { _x( 'Low', 'Severity label for issues rated below 3.', 'jetpack-scan' ) }
24
+ </Badge>
25
+ );
22
26
  };
23
27
 
24
28
  export default ThreatSeverityBadge;
@@ -7,12 +7,11 @@ import {
7
7
  wordpress as coreIcon,
8
8
  } from '@wordpress/icons';
9
9
 
10
- export const THREAT_STATUSES: { value: string; label: string; variant?: 'success' | 'warning' }[] =
11
- [
12
- { value: 'current', label: __( 'Active', 'jetpack-scan' ), variant: 'warning' },
13
- { value: 'fixed', label: __( 'Fixed', 'jetpack-scan' ), variant: 'success' },
14
- { value: 'ignored', label: __( 'Ignored', 'jetpack-scan' ) },
15
- ];
10
+ export const THREAT_STATUSES: { value: string; label: string; intent?: 'medium' | 'stable' }[] = [
11
+ { value: 'current', label: __( 'Active', 'jetpack-scan' ), intent: 'medium' },
12
+ { value: 'fixed', label: __( 'Fixed', 'jetpack-scan' ), intent: 'stable' },
13
+ { value: 'ignored', label: __( 'Ignored', 'jetpack-scan' ) },
14
+ ];
16
15
 
17
16
  export const THREAT_TYPES = [
18
17
  { value: 'plugins', label: __( 'Plugin', 'jetpack-scan' ) },
@@ -47,3 +46,4 @@ export const THREAT_FIELD_AUTO_FIX = 'auto-fix';
47
46
  export const THREAT_ACTION_FIX = 'fix';
48
47
  export const THREAT_ACTION_IGNORE = 'ignore';
49
48
  export const THREAT_ACTION_UNIGNORE = 'unignore';
49
+ export const THREAT_ACTION_VIEW = 'view';
@@ -1,12 +1,11 @@
1
- import { Badge } from '@automattic/jetpack-components';
2
1
  import {
3
2
  type Action,
4
3
  type ActionButton,
5
4
  type Field,
6
5
  type FieldTypeName,
7
6
  type Filter,
7
+ type RenderModalProps,
8
8
  type SortDirection,
9
- type SupportedLayouts,
10
9
  type View,
11
10
  DataViews,
12
11
  filterSortAndPaginate,
@@ -14,13 +13,23 @@ import {
14
13
  import { dateI18n } from '@wordpress/date';
15
14
  import { __ } from '@wordpress/i18n';
16
15
  import { Icon } from '@wordpress/icons';
17
- import { useCallback, useMemo, useState } from 'react';
16
+ import { Badge } from '@wordpress/ui';
17
+ import {
18
+ useCallback,
19
+ useEffect,
20
+ useMemo,
21
+ useRef,
22
+ useState,
23
+ type ReactElement,
24
+ type ReactNode,
25
+ } from 'react';
18
26
  import { ThreatSeverityBadge, getThreatType, type Threat } from '@automattic/jetpack-scan';
19
27
  import ThreatFixerButton from '../threat-fixer-button/index.tsx';
20
28
  import {
21
29
  THREAT_ACTION_FIX,
22
30
  THREAT_ACTION_IGNORE,
23
31
  THREAT_ACTION_UNIGNORE,
32
+ THREAT_ACTION_VIEW,
24
33
  THREAT_FIELD_AUTO_FIX,
25
34
  THREAT_FIELD_DESCRIPTION,
26
35
  THREAT_FIELD_EXTENSION,
@@ -44,16 +53,32 @@ import ThreatsStatusToggleGroupControl from './threats-status-toggle-group-contr
44
53
  /**
45
54
  * DataViews component for displaying security threats.
46
55
  *
47
- * @param {object} props - Component props.
48
- * @param {Array} props.data - Threats data.
49
- * @param {Array} props.filters - Initial DataView filters.
50
- * @param {Function} props.onChangeSelection - Callback function run when an item is selected.
51
- * @param {Function} props.onFixThreats - Threat fix action callback.
52
- * @param {Function} props.onIgnoreThreats - Threat ignore action callback.
53
- * @param {Function} props.onUnignoreThreats - Threat unignore action callback.
54
- * @param {Function} props.isThreatEligibleForFix - Function to determine if a threat is eligible for fixing.
55
- * @param {Function} props.isThreatEligibleForIgnore - Function to determine if a threat is eligible for ignoring.
56
- * @param {Function} props.isThreatEligibleForUnignore - Function to determine if a threat is eligible for unignoring.
56
+ * Each row action (Auto-fix / Ignore / Unignore) supports two wiring shapes:
57
+ * pass a callback (`onFixThreats` etc.) for the existing fire-and-forget
58
+ * behaviour, or pass a React component via the matching `Render*Modal` prop
59
+ * to open a confirmation modal DataViews renders it inline when the
60
+ * action is invoked, and consumers receive `{ items, closeModal }` from
61
+ * `RenderModalProps< Threat >`. Render-modal props take precedence when
62
+ * both are supplied for the same action.
63
+ *
64
+ * @param {object} props - Component props.
65
+ * @param {Array} props.data - Threats data.
66
+ * @param {Array} props.filters - Initial DataView filters.
67
+ * @param {Function} props.onChangeSelection - Callback function run when an item is selected.
68
+ * @param {Function} props.onFixThreats - Threat fix action callback (used when no `RenderFixModal`).
69
+ * @param {Function} props.onIgnoreThreats - Threat ignore action callback (used when no `RenderIgnoreModal`).
70
+ * @param {Function} props.onUnignoreThreats - Threat unignore action callback (used when no `RenderUnignoreModal`).
71
+ * @param {Function} props.RenderFixModal - Optional component rendered as the fix-action modal.
72
+ * @param {Function} props.RenderIgnoreModal - Optional component rendered as the ignore-action modal.
73
+ * @param {Function} props.RenderUnignoreModal - Optional component rendered as the unignore-action modal.
74
+ * @param {Function} props.RenderViewModal - Optional component rendered as the view-details modal. Unlike the fix / ignore / unignore actions, this one is always eligible for any row.
75
+ * @param {Function} props.isThreatEligibleForFix - Function to determine if a threat is eligible for fixing.
76
+ * @param {Function} props.isThreatEligibleForIgnore - Function to determine if a threat is eligible for ignoring.
77
+ * @param {Function} props.isThreatEligibleForUnignore - Function to determine if a threat is eligible for unignoring.
78
+ * @param {ReactNode} [props.empty] - Empty-state node forwarded to DataViews when `data` is empty. Defaults to DataViews' built-in "no items" body.
79
+ * @param {boolean} [props.showStatusFilter] - Whether to render the active/historic status toggle above the table. Defaults to `true`. Set to `false` when the consumer already filters the dataset by status outside the component (e.g. page-level tabs).
80
+ * @param {Function} [props.onTrackEvent] - Optional callback that receives DataViews-canonical event names (`view_change`, `filter_change`, `search`, `page_change`, `layout_changed`) on the matching view transitions. Consumers prefix and forward to their own analytics client.
81
+ * @param {string} [props.persistKey] - Optional `localStorage` key. When set, the component hydrates its initial view state from `localStorage[persistKey]` and writes back on every change. Use stable, namespaced keys (e.g. `jetpack-scan:active-threats:view`) so consumer panels don't collide. Quietly no-ops when `localStorage` is unavailable.
57
82
  *
58
83
  * @return {JSX.Element} The ThreatsDataViews component.
59
84
  */
@@ -67,6 +92,14 @@ export default function ThreatsDataViews( {
67
92
  onFixThreats,
68
93
  onIgnoreThreats,
69
94
  onUnignoreThreats,
95
+ RenderFixModal,
96
+ RenderIgnoreModal,
97
+ RenderUnignoreModal,
98
+ RenderViewModal,
99
+ empty,
100
+ showStatusFilter = true,
101
+ onTrackEvent,
102
+ persistKey,
70
103
  }: {
71
104
  data: Threat[];
72
105
  filters?: Filter[];
@@ -77,6 +110,14 @@ export default function ThreatsDataViews( {
77
110
  onFixThreats?: ( threats: Threat[] ) => void;
78
111
  onIgnoreThreats?: ActionButton< Threat >[ 'callback' ];
79
112
  onUnignoreThreats?: ActionButton< Threat >[ 'callback' ];
113
+ RenderFixModal?: ( props: RenderModalProps< Threat > ) => ReactElement;
114
+ RenderIgnoreModal?: ( props: RenderModalProps< Threat > ) => ReactElement;
115
+ RenderUnignoreModal?: ( props: RenderModalProps< Threat > ) => ReactElement;
116
+ RenderViewModal?: ( props: RenderModalProps< Threat > ) => ReactElement;
117
+ empty?: ReactNode;
118
+ showStatusFilter?: boolean;
119
+ onTrackEvent?: ( event: string, properties?: Record< string, unknown > ) => void;
120
+ persistKey?: string;
80
121
  } ): JSX.Element {
81
122
  const baseView = {
82
123
  sort: {
@@ -96,7 +137,7 @@ export default function ThreatsDataViews( {
96
137
  *
97
138
  * @see https://developer.wordpress.org/block-editor/reference-guides/packages/packages-dataviews/#defaultlayouts-record-string-view
98
139
  */
99
- const defaultLayouts: SupportedLayouts = {
140
+ const defaultLayouts = {
100
141
  table: {
101
142
  ...baseView,
102
143
  fields: [ THREAT_FIELD_SEVERITY, THREAT_FIELD_TYPE, THREAT_FIELD_AUTO_FIX ],
@@ -121,13 +162,40 @@ export default function ThreatsDataViews( {
121
162
  /**
122
163
  * DataView view object - configures how the dataset is visible to the user.
123
164
  *
165
+ * When `persistKey` is supplied, the initial view hydrates from
166
+ * `localStorage[persistKey]` so reloads, tab changes, and drill-ins
167
+ * preserve the user's filters / sort / pagination / layout. Falls
168
+ * back to the default table view on parse failure or first load.
169
+ *
124
170
  * @see https://developer.wordpress.org/block-editor/reference-guides/packages/packages-dataviews/#view-object
125
171
  */
126
- const [ view, setView ] = useState< View >( {
127
- type: 'table',
128
- ...defaultLayouts.table,
172
+ const [ view, setView ] = useState< View >( () => {
173
+ const fallback: View = { type: 'table', ...defaultLayouts.table };
174
+ if ( ! persistKey || typeof window === 'undefined' ) {
175
+ return fallback;
176
+ }
177
+ try {
178
+ const stored = window.localStorage.getItem( persistKey );
179
+ if ( stored ) {
180
+ return JSON.parse( stored ) as View;
181
+ }
182
+ } catch {
183
+ // localStorage may be disabled (privacy mode, full disk) — no-op.
184
+ }
185
+ return fallback;
129
186
  } );
130
187
 
188
+ useEffect( () => {
189
+ if ( ! persistKey || typeof window === 'undefined' ) {
190
+ return;
191
+ }
192
+ try {
193
+ window.localStorage.setItem( persistKey, JSON.stringify( view ) );
194
+ } catch {
195
+ // localStorage may be disabled (privacy mode, full disk) — no-op.
196
+ }
197
+ }, [ persistKey, view ] );
198
+
131
199
  /**
132
200
  * Compute values from the provided threats data.
133
201
  *
@@ -253,10 +321,10 @@ export default function ThreatsDataViews( {
253
321
  if ( item.status ) {
254
322
  const status = THREAT_STATUSES.find( ( { value } ) => value === item.status );
255
323
  if ( status ) {
256
- return <Badge variant={ status?.variant }>{ status.label }</Badge>;
324
+ return <Badge intent={ status.intent }>{ status.label }</Badge>;
257
325
  }
258
326
  }
259
- return <Badge variant="warning">{ __( 'Active', 'jetpack-scan' ) }</Badge>;
327
+ return <Badge intent="medium">{ __( 'Active', 'jetpack-scan' ) }</Badge>;
260
328
  },
261
329
  },
262
330
  {
@@ -419,56 +487,109 @@ export default function ThreatsDataViews( {
419
487
  const result: Action< Threat >[] = [];
420
488
 
421
489
  if ( dataFields.includes( 'fixable' ) ) {
422
- result.push( {
423
- id: THREAT_ACTION_FIX,
424
- label: __( 'Auto-fix', 'jetpack-scan' ),
425
- isPrimary: true,
426
- callback: onFixThreats,
427
- isEligible( item ) {
428
- if ( ! onFixThreats ) {
429
- return false;
430
- }
431
- if ( isThreatEligibleForFix ) {
432
- return isThreatEligibleForFix( item );
433
- }
434
- return !! item.fixable;
435
- },
436
- } );
490
+ const isEligible = ( item: Threat ) => {
491
+ if ( ! RenderFixModal && ! onFixThreats ) {
492
+ return false;
493
+ }
494
+ if ( isThreatEligibleForFix ) {
495
+ return isThreatEligibleForFix( item );
496
+ }
497
+ return !! item.fixable;
498
+ };
499
+ if ( RenderFixModal ) {
500
+ result.push( {
501
+ id: THREAT_ACTION_FIX,
502
+ label: __( 'Auto-fix', 'jetpack-scan' ),
503
+ isPrimary: true,
504
+ modalHeader: __( 'Fix threat', 'jetpack-scan' ),
505
+ RenderModal: RenderFixModal,
506
+ isEligible,
507
+ } );
508
+ } else {
509
+ result.push( {
510
+ id: THREAT_ACTION_FIX,
511
+ label: __( 'Auto-fix', 'jetpack-scan' ),
512
+ isPrimary: true,
513
+ callback: onFixThreats,
514
+ isEligible,
515
+ } );
516
+ }
437
517
  }
438
518
 
439
519
  if ( dataFields.includes( 'status' ) ) {
440
- result.push( {
441
- id: THREAT_ACTION_IGNORE,
442
- label: __( 'Ignore', 'jetpack-scan' ),
443
- isPrimary: true,
444
- callback: onIgnoreThreats,
445
- isEligible( item ) {
446
- if ( ! onIgnoreThreats ) {
447
- return false;
448
- }
449
- if ( isThreatEligibleForIgnore ) {
450
- return isThreatEligibleForIgnore( item );
451
- }
452
- return item.status === 'current';
453
- },
454
- } );
520
+ const isEligible = ( item: Threat ) => {
521
+ if ( ! RenderIgnoreModal && ! onIgnoreThreats ) {
522
+ return false;
523
+ }
524
+ if ( isThreatEligibleForIgnore ) {
525
+ return isThreatEligibleForIgnore( item );
526
+ }
527
+ return item.status === 'current';
528
+ };
529
+ if ( RenderIgnoreModal ) {
530
+ result.push( {
531
+ id: THREAT_ACTION_IGNORE,
532
+ label: __( 'Ignore', 'jetpack-scan' ),
533
+ isPrimary: true,
534
+ modalHeader: __( 'Ignore threat', 'jetpack-scan' ),
535
+ RenderModal: RenderIgnoreModal,
536
+ isEligible,
537
+ } );
538
+ } else {
539
+ result.push( {
540
+ id: THREAT_ACTION_IGNORE,
541
+ label: __( 'Ignore', 'jetpack-scan' ),
542
+ isPrimary: true,
543
+ callback: onIgnoreThreats,
544
+ isEligible,
545
+ } );
546
+ }
455
547
  }
456
548
 
457
549
  if ( dataFields.includes( 'status' ) ) {
550
+ const isEligible = ( item: Threat ) => {
551
+ if ( ! RenderUnignoreModal && ! onUnignoreThreats ) {
552
+ return false;
553
+ }
554
+ if ( isThreatEligibleForUnignore ) {
555
+ return isThreatEligibleForUnignore( item );
556
+ }
557
+ return item.status === 'ignored';
558
+ };
559
+ if ( RenderUnignoreModal ) {
560
+ result.push( {
561
+ id: THREAT_ACTION_UNIGNORE,
562
+ label: __( 'Unignore', 'jetpack-scan' ),
563
+ isPrimary: true,
564
+ modalHeader: __( 'Unignore threat', 'jetpack-scan' ),
565
+ RenderModal: RenderUnignoreModal,
566
+ isEligible,
567
+ } );
568
+ } else {
569
+ result.push( {
570
+ id: THREAT_ACTION_UNIGNORE,
571
+ label: __( 'Unignore', 'jetpack-scan' ),
572
+ isPrimary: true,
573
+ callback: onUnignoreThreats,
574
+ isEligible,
575
+ } );
576
+ }
577
+ }
578
+
579
+ // View details — always-eligible row action that opens the
580
+ // supplied `RenderViewModal`. Unlike fix / ignore / unignore, it
581
+ // is NOT gated by threat status or capability, so the user can
582
+ // always drill in for the full file context, fix description,
583
+ // and metadata.
584
+ if ( RenderViewModal ) {
458
585
  result.push( {
459
- id: THREAT_ACTION_UNIGNORE,
460
- label: __( 'Unignore', 'jetpack-scan' ),
461
- isPrimary: true,
462
- callback: onUnignoreThreats,
463
- isEligible( item ) {
464
- if ( ! onUnignoreThreats ) {
465
- return false;
466
- }
467
- if ( isThreatEligibleForUnignore ) {
468
- return isThreatEligibleForUnignore( item );
469
- }
470
- return item.status === 'ignored';
471
- },
586
+ id: THREAT_ACTION_VIEW,
587
+ label: __( 'View details', 'jetpack-scan' ),
588
+ isPrimary: false,
589
+ modalHeader: __( 'Threat details', 'jetpack-scan' ),
590
+ modalSize: 'large',
591
+ RenderModal: RenderViewModal,
592
+ isEligible: () => true,
472
593
  } );
473
594
  }
474
595
 
@@ -478,6 +599,10 @@ export default function ThreatsDataViews( {
478
599
  onFixThreats,
479
600
  onIgnoreThreats,
480
601
  onUnignoreThreats,
602
+ RenderFixModal,
603
+ RenderIgnoreModal,
604
+ RenderUnignoreModal,
605
+ RenderViewModal,
481
606
  isThreatEligibleForFix,
482
607
  isThreatEligibleForIgnore,
483
608
  isThreatEligibleForUnignore,
@@ -493,13 +618,42 @@ export default function ThreatsDataViews( {
493
618
  }, [ data, view, fields ] );
494
619
 
495
620
  /**
496
- * Callback function to update the view state.
621
+ * Callback function to update the view state. When `onTrackEvent` is
622
+ * supplied, diff the previous view against the new one and fire the
623
+ * matching DataViews-canonical event names so consumer analytics can
624
+ * track which dimension actually changed (search vs filter vs page
625
+ * vs layout). The generic `view_change` event always fires last so
626
+ * consumers can choose between the granular events and an "anything
627
+ * changed" hook.
497
628
  *
498
629
  * @see https://developer.wordpress.org/block-editor/reference-guides/packages/packages-dataviews/#onchangeview-function
499
630
  */
500
- const onChangeView = useCallback( ( newView: View ) => {
501
- setView( newView );
502
- }, [] );
631
+ const previousViewRef = useRef< View >( view );
632
+ const onChangeView = useCallback(
633
+ ( newView: View ) => {
634
+ if ( onTrackEvent ) {
635
+ const previous = previousViewRef.current;
636
+ if ( previous.search !== newView.search ) {
637
+ onTrackEvent( 'search', { has_query: !! newView.search } );
638
+ }
639
+ if ( previous.type !== newView.type ) {
640
+ onTrackEvent( 'layout_changed', { layout: newView.type } );
641
+ }
642
+ if ( previous.page !== newView.page ) {
643
+ onTrackEvent( 'page_change', { page: newView.page } );
644
+ }
645
+ if (
646
+ JSON.stringify( previous.filters ?? [] ) !== JSON.stringify( newView.filters ?? [] )
647
+ ) {
648
+ onTrackEvent( 'filter_change' );
649
+ }
650
+ onTrackEvent( 'view_change' );
651
+ }
652
+ previousViewRef.current = newView;
653
+ setView( newView );
654
+ },
655
+ [ onTrackEvent ]
656
+ );
503
657
 
504
658
  /**
505
659
  * DataView getItemId function - returns the unique ID for each record in the dataset.
@@ -509,23 +663,28 @@ export default function ThreatsDataViews( {
509
663
  const getItemId = useCallback( ( item: Threat ) => item.id.toString(), [] );
510
664
 
511
665
  return (
512
- <DataViews
513
- actions={ actions }
514
- data={ processedData }
515
- defaultLayouts={ defaultLayouts }
516
- fields={ fields }
517
- getItemId={ getItemId }
518
- onChangeSelection={ onChangeSelection }
519
- onChangeView={ onChangeView }
520
- paginationInfo={ paginationInfo }
521
- view={ view }
522
- header={
523
- <ThreatsStatusToggleGroupControl
524
- data={ data }
525
- view={ view }
526
- onChangeView={ onChangeView }
527
- />
528
- }
529
- />
666
+ <div className={ styles[ 'threats-data-views' ] }>
667
+ <DataViews
668
+ actions={ actions }
669
+ data={ processedData }
670
+ defaultLayouts={ defaultLayouts }
671
+ fields={ fields }
672
+ getItemId={ getItemId }
673
+ onChangeSelection={ onChangeSelection }
674
+ onChangeView={ onChangeView }
675
+ paginationInfo={ paginationInfo }
676
+ view={ view }
677
+ empty={ empty }
678
+ header={
679
+ showStatusFilter ? (
680
+ <ThreatsStatusToggleGroupControl
681
+ data={ data }
682
+ view={ view }
683
+ onChangeView={ onChangeView }
684
+ />
685
+ ) : undefined
686
+ }
687
+ />
688
+ </div>
530
689
  );
531
690
  }
@@ -1,6 +1,29 @@
1
1
  @import "@wordpress/theme/design-tokens.css";
2
2
  @import "@wordpress/dataviews/build-style/style.css";
3
3
 
4
+ // Wrapper carries the flex chain forward to `<DataViews>` (whose root
5
+ // `.dataviews-wrapper` is `height: 100%`) and scopes the empty-state
6
+ // override below — `:global(.dataviews-no-results)` only matches inside
7
+ // this wrapper because the outer class is module-hashed.
8
+ .threats-data-views {
9
+ display: flex;
10
+ flex: 1 1 auto;
11
+ flex-direction: column;
12
+ min-block-size: 0;
13
+
14
+ // DataViews ships `.dataviews-no-results` with `flex-grow: 1`, so it
15
+ // fills its parent — but only if that parent has a defined height.
16
+ // Anchor the empty body to the viewport (minus admin bar + page
17
+ // header + tab row + footer + a small breathing margin) so the empty
18
+ // state always reads as full-height and the surrounding page chain
19
+ // gets pushed to the bottom of the viewport even when DataViews is
20
+ // the only thing in the panel. Min-height (not height) so a populated
21
+ // table still scrolls naturally.
22
+ :global(.dataviews-no-results) {
23
+ min-block-size: calc(100vh - 320px);
24
+ }
25
+ }
26
+
4
27
  .threat__title {
5
28
  color: var(--jp-gray-80);
6
29
  font-weight: 510;
@@ -1,4 +0,0 @@
1
- export { default as ThreatFixerButton } from './threat-fixer-button/index.tsx';
2
- export { default as ThreatModal } from './threat-modal/index.tsx';
3
- export { default as ThreatSeverityBadge } from './threat-severity-badge/index.tsx';
4
- export { default as ThreatsDataViews } from './threats-data-views/index.tsx';
@@ -1,4 +0,0 @@
1
- export { default as ThreatFixerButton } from './threat-fixer-button/index.js';
2
- export { default as ThreatModal } from './threat-modal/index.js';
3
- export { default as ThreatSeverityBadge } from './threat-severity-badge/index.js';
4
- export { default as ThreatsDataViews } from './threats-data-views/index.js';
@@ -1,16 +0,0 @@
1
- import { type Threat } from '@automattic/jetpack-scan';
2
- /**
3
- * Threat Fixer Button component.
4
- *
5
- * @param {object} props - Component props.
6
- * @param {object} props.threat - The threat.
7
- * @param {Function} props.onClick - The onClick function.
8
- * @param {string} props.className - The className.
9
- *
10
- * @return {JSX.Element} The component.
11
- */
12
- export default function ThreatFixerButton({ threat, className, onClick }: {
13
- threat: Threat;
14
- onClick: (items: Threat[]) => void;
15
- className?: string;
16
- }): JSX.Element;