Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ThreatFixerButton: Add tooltips #40065

Closed
wants to merge 25 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: minor
Type: added

Adds tooltips for each ThreatFixerButton state
194 changes: 135 additions & 59 deletions projects/js-packages/components/components/threat-fixer-button/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { Button, Text, ActionPopover } from '@automattic/jetpack-components';
import { CONTACT_SUPPORT_URL, type Threat, fixerStatusIsStale } from '@automattic/jetpack-scan';
import { Button, IconTooltip } from '@automattic/jetpack-components';
import {
CONTACT_SUPPORT_URL,
type Threat,
fixerIsInError,
fixerIsInProgress,
fixerStatusIsStale,
} from '@automattic/jetpack-scan';
import { ExternalLink } from '@wordpress/components';
import { createInterpolateElement, useCallback, useMemo, useState } from '@wordpress/element';
import { __, sprintf } from '@wordpress/i18n';
Expand All @@ -24,56 +30,130 @@ export default function ThreatFixerButton( {
onClick: ( items: Threat[] ) => void;
className?: string;
} ): JSX.Element {
const [ isPopoverVisible, setIsPopoverVisible ] = useState( false );
const [ showTooltip, setShowTooltip ] = useState( false );

const [ anchor, setAnchor ] = useState( null );
const fixerState = useMemo( () => {
const inProgress = threat.fixer && fixerIsInProgress( threat.fixer );
const error = threat.fixer && fixerIsInError( threat.fixer );
const stale = threat.fixer && fixerStatusIsStale( threat.fixer );
return { inProgress, error, stale };
}, [ threat.fixer ] );

const errorMessage = useMemo( () => {
if ( fixerState.stale ) {
return __( 'The auto-fixer is taking longer than expected.', 'jetpack' );
}

if ( fixerState.error ) {
return __( 'An error occurred auto-fixing this threat.', 'jetpack' );
}

const children = useMemo( () => {
return null;
}, [ fixerState ] );

const popoverText = useMemo( () => {
if ( ! threat.fixable ) {
return null;
}
if ( threat.fixer && 'error' in threat.fixer && threat.fixer.error ) {
return __( 'Error', 'jetpack' );
}
if ( threat.fixer && 'status' in threat.fixer && threat.fixer.status === 'in_progress' ) {
return __( 'Fixing…', 'jetpack' );

if ( fixerState.inProgress ) {
return __(
'An auto-fixer is in progress. This may take a moment, please check back shortly.',
'jetpack'
);
}
if ( threat.fixable.fixer === 'delete' ) {
return __( 'Delete', 'jetpack' );

switch ( threat.fixable.fixer ) {
case 'delete':
if ( threat.filename ) {
if ( threat.filename.endsWith( '/' ) ) {
return __( 'Deletes the directory that the infected file is in.', 'jetpack' );
}

if ( threat.signature === 'Core.File.Modification' ) {
return __( 'Deletes the unexpected file in a core WordPress directory.', 'jetpack' );
}

return __( 'Deletes the infected file.', 'jetpack' );
}

if ( threat.extension?.type === 'plugin' ) {
return __( 'Deletes the plugin directory to fix the threat.', 'jetpack' );
}

if ( threat.extension?.type === 'theme' ) {
return __( 'Deletes the theme directory to fix the threat.', 'jetpack' );
}
break;
case 'update':
return __( 'Upgrades the plugin or theme to a newer version.', 'jetpack' );
case 'replace':
case 'rollback':
if ( threat.filename ) {
return threat.signature === 'Core.File.Modification'
? __(
'Replaces the modified core WordPress file with the original clean version from the WordPress source code.',
'jetpack'
)
: __(
'Replaces the infected file with a previously backed up version that is clean.',
'jetpack'
);
}

if ( threat.signature === 'php_hardening_WP_Config_NoSalts_001' ) {
return __(
'Replaces the default salt keys in wp-config.php with unique ones.',
'jetpack'
);
}
break;
default:
return __( 'An auto-fixer is available.', 'jetpack' );
}
if ( threat.fixable.fixer === 'update' ) {
return __( 'Update', 'jetpack' );
}, [ threat, fixerState.inProgress ] );

const buttonText = useMemo( () => {
if ( ! threat.fixable ) {
return null;
}
return __( 'Fix', 'jetpack' );
}, [ threat.fixable, threat.fixer ] );

const errorMessage = useMemo( () => {
if ( threat.fixer && fixerStatusIsStale( threat.fixer ) ) {
return __( 'The fixer is taking longer than expected.', 'jetpack' );
if ( fixerState.error ) {
return __( 'Error', 'jetpack' );
}

if ( threat.fixer && 'error' in threat.fixer && threat.fixer.error ) {
return __( 'An error occurred auto-fixing this threat.', 'jetpack' );
if ( fixerState.inProgress ) {
return __( 'Fixing…', 'jetpack' );
}

return null;
}, [ threat.fixer ] );
switch ( threat.fixable.fixer ) {
case 'delete':
return __( 'Delete', 'jetpack' );
case 'update':
return __( 'Update', 'jetpack' );
case 'replace':
case 'rollback':
return __( 'Replace', 'jetpack' );
default:
return __( 'Fix', 'jetpack' );
}
}, [ threat.fixable, fixerState ] );

const handleClick = useCallback(
( event: React.MouseEvent ) => {
event.stopPropagation();
if ( errorMessage && ! isPopoverVisible ) {
setIsPopoverVisible( true );
return;
}
onClick( [ threat ] );
},
[ onClick, errorMessage, isPopoverVisible, threat ]
[ onClick, threat ]
);

const closePopover = useCallback( () => {
setIsPopoverVisible( false );
}, [] );
const handleErrorClick = useCallback(
( event: React.MouseEvent ) => {
event.stopPropagation();
setShowTooltip( ! showTooltip );
},
[ showTooltip ]
);

if ( ! threat.fixable ) {
return null;
Expand All @@ -85,37 +165,28 @@ export default function ThreatFixerButton( {
size="small"
weight="regular"
variant="secondary"
onClick={ handleClick }
children={ children }
onClick={ errorMessage ? handleErrorClick : handleClick }
children={ buttonText }
className={ className }
disabled={
threat.fixer &&
'status' in threat.fixer &&
threat.fixer.status === 'in_progress' &&
! errorMessage
}
isLoading={
threat.fixer && 'status' in threat.fixer && threat.fixer.status === 'in_progress'
}
disabled={ fixerState.inProgress && ! errorMessage }
isLoading={ fixerState.inProgress }
isDestructive={
( threat.fixable && threat.fixable.fixer === 'delete' ) ||
( threat.fixer && 'error' in threat.fixer && threat.fixer.error ) ||
( threat.fixer && fixerStatusIsStale( threat.fixer ) )
fixerState.error ||
fixerState.stale
}
style={ { minWidth: '72px' } }
ref={ setAnchor }
/>
{ isPopoverVisible && (
<ActionPopover
anchor={ anchor }
buttonContent={ __( 'Retry Fix', 'jetpack' ) }
hideCloseButton={ true }
noArrow={ false }
onClick={ handleClick }
onClose={ closePopover }
title={ __( 'Auto-fix error', 'jetpack' ) }
>
<Text>
<IconTooltip
className={ styles.tooltip }
hoverShow
forceShow={ showTooltip }
popoverAnchorStyle="wrapper"
placement="bottom"
offset={ -5 }
>
{ errorMessage ? (
<>
{ createInterpolateElement(
sprintf(
/* translators: placeholder is an error message. */
Expand All @@ -134,9 +205,14 @@ export default function ThreatFixerButton( {
),
}
) }
</Text>
</ActionPopover>
) }
<Button className={ styles.retry } size="small" onClick={ handleClick }>
{ __( 'Retry fix', 'jetpack' ) }
</Button>
</>
) : (
popoverText
) }
</IconTooltip>
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,59 @@ import ThreatFixerButton from '../index.js';
export default {
title: 'JS Packages/Components/Threat Fixer Button',
component: ThreatFixerButton,
decorators: [
Story => (
<div style={ { height: '175px' } }>
<Story />
</div>
),
],
parameters: {
layout: 'centered',
},
};

export const Default = args => <ThreatFixerButton { ...args } />;
Default.args = {
threat: { fixable: { fixer: 'edit' } },
onClick: () => alert( 'Edit fixer callback triggered' ), // eslint-disable-line no-alert
onClick: () => alert( 'Fixer callback triggered' ), // eslint-disable-line no-alert
};

export const DeletePlugin = args => <ThreatFixerButton { ...args } />;
DeletePlugin.args = {
threat: { fixable: { fixer: 'delete' }, extension: { type: 'plugin' } },
onClick: () => alert( 'Delete fixer callback triggered' ), // eslint-disable-line no-alert
};

export const DeleteTheme = args => <ThreatFixerButton { ...args } />;
DeleteTheme.args = {
threat: { fixable: { fixer: 'delete' }, extension: { type: 'theme' } },
onClick: () => alert( 'Delete fixer callback triggered' ), // eslint-disable-line no-alert
};

export const DeleteDirectory = args => <ThreatFixerButton { ...args } />;
DeleteDirectory.args = {
threat: { fixable: { fixer: 'delete' }, filename: '/var/www/html/wp-content/uploads/' },
onClick: () => alert( 'Delete fixer callback triggered' ), // eslint-disable-line no-alert
};

export const DeleteCoreFile = args => <ThreatFixerButton { ...args } />;
DeleteCoreFile.args = {
threat: {
fixable: { fixer: 'delete' },
signature: 'Core.File.Modification',
filename: '/var/www/html/wp-admin/index.php',
},
onClick: () => alert( 'Delete fixer callback triggered' ), // eslint-disable-line no-alert
};

export const DeleteFile = args => <ThreatFixerButton { ...args } />;
DeleteFile.args = {
threat: {
fixable: { fixer: 'delete' },
filename: '/var/www/html/wp-content/uploads/jptt_eicar.php',
},
onClick: () => alert( 'Delete fixer callback triggered' ), // eslint-disable-line no-alert
};

export const Update = args => <ThreatFixerButton { ...args } />;
Expand All @@ -17,14 +64,48 @@ Update.args = {
onClick: () => alert( 'Update fixer callback triggered' ), // eslint-disable-line no-alert
};

export const Delete = args => <ThreatFixerButton { ...args } />;
Delete.args = {
threat: { fixable: { fixer: 'delete' } },
onClick: () => alert( 'Delete fixer callback triggered' ), // eslint-disable-line no-alert
export const ReplaceSaltKeys = args => <ThreatFixerButton { ...args } />;
ReplaceSaltKeys.args = {
threat: { fixable: { fixer: 'replace' }, signature: 'php_hardening_WP_Config_NoSalts_001' },
onClick: () => alert( 'Replace fixer callback triggered' ), // eslint-disable-line no-alert
};

export const ReplaceCoreFile = args => <ThreatFixerButton { ...args } />;
ReplaceCoreFile.args = {
threat: {
fixable: { fixer: 'replace' },
signature: 'Core.File.Modification',
filename: '/var/www/html/wp-admin/index.php',
},
onClick: () => alert( 'Replace fixer callback triggered' ), // eslint-disable-line no-alert
};

export const ReplaceFile = args => <ThreatFixerButton { ...args } />;
ReplaceFile.args = {
threat: {
fixable: { fixer: 'replace' },
filename: '/var/www/html/wp-content/uploads/jptt_eicar.php',
},
onClick: () => alert( 'Replace fixer callback triggered' ), // eslint-disable-line no-alert
};

export const Loading = args => <ThreatFixerButton { ...args } />;
Loading.args = {
threat: { fixable: { fixer: 'edit' }, fixer: { status: 'in_progress' } },
onClick: () => alert( 'Fixer callback triggered' ), // eslint-disable-line no-alert
threat: { fixable: { fixer: 'update' }, fixer: { status: 'in_progress' } },
onClick: () => alert( 'Update fixer callback triggered' ), // eslint-disable-line no-alert
};

export const StaleFixer = args => <ThreatFixerButton { ...args } />;
StaleFixer.args = {
threat: {
fixable: { fixer: 'update' },
fixer: { status: 'in_progress', lastUpdated: new Date( '1999-01-01' ).toISOString() },
},
onClick: () => alert( 'Update fixer callback triggered.' ), // eslint-disable-line no-alert
};

export const ErrorFixer = args => <ThreatFixerButton { ...args } />;
ErrorFixer.args = {
threat: { fixable: { fixer: 'update' }, fixer: { error: 'error' } },
onClick: () => alert( 'Update fixer callback triggered.' ), // eslint-disable-line no-alert
};
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,18 @@
box-shadow: none;
}
}

.tooltip {
display: block;
width: 72px;
height: 28px;

:global( .icon-tooltip-helper ) {
width: 100%;
}
}

.retry {
display: block;
margin-left: auto;
}
Loading
Loading