Skip to content

Commit 690d06a

Browse files
authored
Scan Dashboard: implement threat fix progress tracking with reusable hook (#106085)
* Add useFixThreats hook * Refactor useFixThreats to use state machine instead of dual booleans * Fix stale threat status by disabling cache in useFixThreats * Fx fetchFixThreatsStatus return type and remove data transformation * Relocate hooks/use-fix-threats.ts * Update FixThreatStatus interface to include 'not_started' and 'not_fixed' statuses * Update useFixThreats hook to include 'not_started' status in pending threats * Add useFixThreats hook unit tests * Refactor FixThreatModal to utilize useFixThreats hook for threat fixing * Refactor BulkFixThreatsModal to utilize useFixThreats hook for threat fixing * Update fix threat buttons label based on state * Update bulk fix threats modal messages pluralization * Add pluralization on auto-fix threats button * Remove persist: false property from fixThreatsStatusQuery
1 parent c6026ea commit 690d06a

File tree

8 files changed

+493
-99
lines changed

8 files changed

+493
-99
lines changed

client/dashboard/sites/scan/components/bulk-fix-threats-modal.tsx

Lines changed: 63 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,11 @@
1-
import {
2-
fixThreatsMutation,
3-
fixThreatsStatusQuery,
4-
siteScanQuery,
5-
siteScanHistoryQuery,
6-
} from '@automattic/api-queries';
7-
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
81
import { __experimentalVStack as VStack, Button } from '@wordpress/components';
92
import { useDispatch } from '@wordpress/data';
10-
import { __ } from '@wordpress/i18n';
3+
import { __, _n } from '@wordpress/i18n';
114
import { store as noticesStore } from '@wordpress/notices';
12-
import { useState, useEffect, useCallback } from 'react';
5+
import { useEffect } from 'react';
136
import { ButtonStack } from '../../../components/button-stack';
147
import { Text } from '../../../components/text';
8+
import { useFixThreats } from '../hooks/use-fix-threats';
159
import { ThreatsDetailCard } from './threats-detail-card';
1610
import type { Threat } from '@automattic/api-core';
1711
import type { RenderModalProps } from '@wordpress/dataviews';
@@ -27,69 +21,74 @@ export function BulkFixThreatsModal( { items, closeModal, siteId }: BulkFixThrea
2721
const bulkFixableIds = new Set( bulkFixableThreats.map( ( item ) => item.id ) );
2822
const remainingThreats = items.filter( ( item ) => ! bulkFixableIds.has( item.id ) );
2923

30-
const queryClient = useQueryClient();
31-
const bulkFixThreats = useMutation( fixThreatsMutation( siteId ) );
3224
const { createSuccessNotice, createErrorNotice } = useDispatch( noticesStore );
33-
const [ isBulkFixInProgress, setIsBulkFixInProgress ] = useState( false );
3425

35-
const { data: bulkFixStatusData } = useQuery( {
36-
...fixThreatsStatusQuery( siteId, Array.from( bulkFixableIds ) ),
37-
refetchInterval: isBulkFixInProgress ? 2000 : false,
38-
enabled: isBulkFixInProgress,
39-
} );
40-
41-
const handleBulkFixed = useCallback(
42-
( message: string ) => {
43-
queryClient.invalidateQueries( siteScanQuery( siteId ) );
44-
queryClient.invalidateQueries( siteScanHistoryQuery( siteId ) );
45-
closeModal?.();
46-
createSuccessNotice( message, { type: 'snackbar' } );
47-
},
48-
[ closeModal, createSuccessNotice, queryClient, siteId ]
26+
const { startFix, isFixing, status, error } = useFixThreats(
27+
siteId,
28+
Array.from( bulkFixableIds )
4929
);
5030

5131
useEffect( () => {
52-
if ( ! bulkFixStatusData?.threats ) {
53-
return;
54-
}
32+
if ( status.isComplete && ! isFixing ) {
33+
closeModal?.();
5534

56-
if ( isBulkFixInProgress ) {
57-
const pendingThreats = bulkFixStatusData.threats.filter(
58-
( threat ) => threat?.status === 'in_progress'
59-
);
60-
if ( pendingThreats.length > 0 ) {
61-
return;
35+
if ( status.allFixed ) {
36+
createSuccessNotice(
37+
_n( 'Threat fixed.', 'All threats were successfully fixed.', bulkFixableThreats.length ),
38+
{
39+
type: 'snackbar',
40+
}
41+
);
42+
} else {
43+
createErrorNotice(
44+
_n(
45+
'Failed to fix threat. Please contact support.',
46+
'Not all threats could be fixed. Please contact support.',
47+
bulkFixableThreats.length
48+
),
49+
{
50+
type: 'snackbar',
51+
}
52+
);
6253
}
54+
}
55+
}, [
56+
status,
57+
isFixing,
58+
closeModal,
59+
createSuccessNotice,
60+
createErrorNotice,
61+
bulkFixableThreats.length,
62+
] );
6363

64-
const fixedThreats = bulkFixStatusData.threats.filter(
65-
( threat ) => threat?.status === 'fixed'
64+
useEffect( () => {
65+
if ( error ) {
66+
closeModal?.();
67+
createErrorNotice(
68+
_n(
69+
'Error fixing threat. Please contact support.',
70+
'Error fixing threats. Please contact support.',
71+
bulkFixableThreats.length
72+
),
73+
{
74+
type: 'snackbar',
75+
}
6676
);
67-
const allFixed = fixedThreats.length === bulkFixStatusData.threats.length;
68-
const message = allFixed
69-
? __( 'All threats were successfully fixed.' )
70-
: __( 'Not all threats could be fixed. Please contact our support.' );
71-
72-
setIsBulkFixInProgress( false );
73-
handleBulkFixed( message );
7477
}
75-
}, [ bulkFixStatusData, isBulkFixInProgress, handleBulkFixed ] );
78+
}, [ error, closeModal, createErrorNotice, bulkFixableThreats.length ] );
7679

7780
const handleFixThreats = () => {
78-
setIsBulkFixInProgress( true );
79-
bulkFixThreats.mutate( Array.from( bulkFixableIds ), {
80-
onError: () => {
81-
closeModal?.();
82-
createErrorNotice( __( 'Error fixing threats. Please contact support.' ), {
83-
type: 'snackbar',
84-
} );
85-
},
86-
} );
81+
startFix();
8782
};
8883

8984
const bulkFixableSection = (
9085
<>
9186
<Text variant="muted">
92-
{ __( 'Jetpack will be fixing the selected threats and low risk items:' ) }
87+
{ _n(
88+
'Jetpack will be fixing the selected threat and low risk item:',
89+
'Jetpack will be fixing the selected threats and low risk items:',
90+
bulkFixableThreats.length
91+
) }
9392
</Text>
9493
<ThreatsDetailCard threats={ bulkFixableThreats } />
9594
</>
@@ -98,8 +97,10 @@ export function BulkFixThreatsModal( { items, closeModal, siteId }: BulkFixThrea
9897
const remainingThreatsSection = (
9998
<>
10099
<Text variant="muted">
101-
{ __(
102-
'These threats cannot be fixed in bulk because individual confirmation is required:'
100+
{ _n(
101+
'This threat cannot be fixed in bulk because individual confirmation is required:',
102+
'These threats cannot be fixed in bulk because individual confirmation is required:',
103+
remainingThreats.length
103104
) }
104105
</Text>
105106
<ThreatsDetailCard threats={ remainingThreats } />
@@ -119,10 +120,12 @@ export function BulkFixThreatsModal( { items, closeModal, siteId }: BulkFixThrea
119120
<Button
120121
variant="primary"
121122
onClick={ handleFixThreats }
122-
isBusy={ isBulkFixInProgress }
123-
disabled={ ! canBulkFix || isBulkFixInProgress }
123+
isBusy={ isFixing }
124+
disabled={ ! canBulkFix || isFixing }
124125
>
125-
{ isBulkFixInProgress ? __( 'Fixing threats…' ) : __( 'Fix all threats' ) }
126+
{ isFixing
127+
? _n( 'Fixing threat…', 'Fixing threats…', bulkFixableThreats.length )
128+
: _n( 'Fix threat', 'Fix all threats', bulkFixableThreats.length ) }
126129
</Button>
127130
</ButtonStack>
128131
</VStack>

client/dashboard/sites/scan/components/fix-threat-modal.tsx

Lines changed: 33 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1-
import { fixThreatMutation } from '@automattic/api-queries';
2-
import { useMutation } from '@tanstack/react-query';
31
import { __experimentalVStack as VStack, Button } from '@wordpress/components';
42
import { useDispatch } from '@wordpress/data';
53
import { __ } from '@wordpress/i18n';
64
import { store as noticesStore } from '@wordpress/notices';
5+
import { useEffect } from 'react';
76
import { ButtonStack } from '../../../components/button-stack';
87
import { Text } from '../../../components/text';
8+
import { useFixThreats } from '../hooks/use-fix-threats';
99
import { FixThreatConfirmation } from './fix-threat-confirmation';
1010
import { ThreatDescription } from './threat-description';
1111
import { ThreatsDetailCard } from './threats-detail-card';
@@ -18,28 +18,37 @@ interface FixThreatModalProps extends RenderModalProps< Threat > {
1818

1919
export function FixThreatModal( { items, closeModal, siteId }: FixThreatModalProps ) {
2020
const threat = items[ 0 ];
21+
const threatIds = [ threat.id ];
2122

22-
const fixThreat = useMutation( fixThreatMutation( siteId ) );
2323
const { createSuccessNotice, createErrorNotice } = useDispatch( noticesStore );
2424

25-
const handleFixThreat = () => {
26-
fixThreat.mutate( threat.id, {
27-
onSuccess: () => {
28-
closeModal?.();
29-
createSuccessNotice(
30-
__(
31-
'We’re hard at work fixing this threat in the background. Please check back shortly.'
32-
),
33-
{ type: 'snackbar' }
34-
);
35-
},
36-
onError: () => {
37-
closeModal?.();
38-
createErrorNotice( __( 'Error fixing threat. Please contact support.' ), {
25+
const { startFix, isFixing, status, error } = useFixThreats( siteId, threatIds );
26+
27+
useEffect( () => {
28+
if ( status.isComplete && ! isFixing ) {
29+
closeModal?.();
30+
31+
if ( status.allFixed ) {
32+
createSuccessNotice( __( 'Threat fixed.' ), { type: 'snackbar' } );
33+
} else {
34+
createErrorNotice( __( 'Failed to fix threat. Please contact support.' ), {
3935
type: 'snackbar',
4036
} );
41-
},
42-
} );
37+
}
38+
}
39+
}, [ status, isFixing, closeModal, createSuccessNotice, createErrorNotice ] );
40+
41+
useEffect( () => {
42+
if ( error ) {
43+
closeModal?.();
44+
createErrorNotice( __( 'Failed to fix threat. Please contact support.' ), {
45+
type: 'snackbar',
46+
} );
47+
}
48+
}, [ error, closeModal, createErrorNotice ] );
49+
50+
const handleFixThreat = () => {
51+
startFix();
4352
};
4453

4554
const isExtensionDeleteFixer =
@@ -55,8 +64,8 @@ export function FixThreatModal( { items, closeModal, siteId }: FixThreatModalPro
5564
threat={ threat }
5665
onCancel={ closeModal }
5766
onConfirm={ handleFixThreat }
58-
disabled={ fixThreat.isPending }
59-
isLoading={ fixThreat.isPending }
67+
disabled={ isFixing }
68+
isLoading={ isFixing }
6069
/>
6170
) : (
6271
<>
@@ -68,10 +77,10 @@ export function FixThreatModal( { items, closeModal, siteId }: FixThreatModalPro
6877
<Button
6978
variant="primary"
7079
onClick={ handleFixThreat }
71-
isBusy={ fixThreat.isPending }
72-
disabled={ fixThreat.isPending }
80+
isBusy={ isFixing }
81+
disabled={ isFixing }
7382
>
74-
{ __( 'Fix threat' ) }
83+
{ isFixing ? __( 'Fixing threat…' ) : __( 'Fix threat' ) }
7584
</Button>
7685
</ButtonStack>
7786
</>
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import {
2+
fixThreatsMutation,
3+
fixThreatsStatusQuery,
4+
siteScanQuery,
5+
siteScanHistoryQuery,
6+
} from '@automattic/api-queries';
7+
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
8+
import { useState, useEffect, useCallback, useMemo } from 'react';
9+
10+
type FixState = 'idle' | 'fixing' | 'completed';
11+
12+
export function useFixThreats( siteId: number, threatIds: number[] ) {
13+
const queryClient = useQueryClient();
14+
const [ fixState, setFixState ] = useState< FixState >( 'idle' );
15+
16+
const fixMutation = useMutation( {
17+
...fixThreatsMutation( siteId ),
18+
onError: () => setFixState( 'idle' ),
19+
} );
20+
21+
const { data: threats = [] } = useQuery( {
22+
...fixThreatsStatusQuery( siteId, threatIds ),
23+
refetchInterval: fixState === 'fixing' ? 2000 : false,
24+
enabled: fixState === 'fixing' && threatIds.length > 0,
25+
select: ( data ) => {
26+
if ( ! data?.threats ) {
27+
return [];
28+
}
29+
return Object.entries( data.threats ).map( ( [ id, threat ] ) => ( {
30+
...threat,
31+
id: Number( id ),
32+
} ) );
33+
},
34+
gcTime: 0, // Always fetch fresh status data, never use cached results
35+
} );
36+
37+
const status = useMemo( () => {
38+
// If we haven't started fixing yet, not complete
39+
if ( fixState === 'idle' ) {
40+
return { isComplete: false, allFixed: false };
41+
}
42+
43+
// If fixing but no threat data yet, not complete
44+
if ( fixState === 'fixing' && threats.length === 0 ) {
45+
return { isComplete: false, allFixed: false };
46+
}
47+
48+
const pending = threats.filter(
49+
( t ) => t.status === 'in_progress' || t.status === 'not_started'
50+
);
51+
const fixed = threats.filter( ( t ) => t.status === 'fixed' );
52+
53+
return {
54+
isComplete: pending.length === 0,
55+
allFixed: fixed.length === threats.length,
56+
};
57+
}, [ threats, fixState ] );
58+
59+
const startFix = useCallback( () => {
60+
setFixState( 'fixing' );
61+
return fixMutation.mutate( threatIds );
62+
}, [ threatIds, fixMutation ] );
63+
64+
useEffect( () => {
65+
if ( status.isComplete && fixState === 'fixing' ) {
66+
setFixState( 'completed' );
67+
queryClient.invalidateQueries( siteScanQuery( siteId ) );
68+
queryClient.invalidateQueries( siteScanHistoryQuery( siteId ) );
69+
}
70+
}, [ status, fixState, queryClient, siteId ] );
71+
72+
return {
73+
startFix,
74+
isFixing: fixState === 'fixing',
75+
status,
76+
error: fixMutation.error,
77+
};
78+
}

client/dashboard/sites/scan/index.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
CardBody,
1212
__experimentalText as Text,
1313
} from '@wordpress/components';
14-
import { __, sprintf } from '@wordpress/i18n';
14+
import { __, _n, sprintf } from '@wordpress/i18n';
1515
import { shield } from '@wordpress/icons';
1616
import { useState } from 'react';
1717
import { siteRoute } from '../../app/router/sites';
@@ -108,7 +108,11 @@ function SiteScan( { scanTab }: { scanTab: 'active' | 'history' } ) {
108108
<Button variant="primary" onClick={ () => setShowBulkFixModal( true ) }>
109109
{ sprintf(
110110
/* translators: %d: number of threats */
111-
__( 'Auto-fix %(threatsCount)d threats' ),
111+
_n(
112+
'Auto-fix %(threatsCount)d threat',
113+
'Auto-fix %(threatsCount)d threats',
114+
fixableThreatsCount
115+
),
112116
{
113117
threatsCount: fixableThreatsCount,
114118
}

0 commit comments

Comments
 (0)