diff --git a/projects/packages/forms/changelog/update-forms-spam-akismet-cta b/projects/packages/forms/changelog/update-forms-spam-akismet-cta new file mode 100644 index 0000000000000..ac0f365a0bca2 --- /dev/null +++ b/projects/packages/forms/changelog/update-forms-spam-akismet-cta @@ -0,0 +1,4 @@ +Significance: patch +Type: added + +Forms: Add CTA to install/activate Akismet on empty spam dashboard diff --git a/projects/packages/forms/src/blocks/contact-form/components/jetpack-integrations-modal/hooks/use-plugin-installation.ts b/projects/packages/forms/src/blocks/contact-form/components/jetpack-integrations-modal/hooks/use-plugin-installation.ts deleted file mode 100644 index 2c3d1b6124e2e..0000000000000 --- a/projects/packages/forms/src/blocks/contact-form/components/jetpack-integrations-modal/hooks/use-plugin-installation.ts +++ /dev/null @@ -1,63 +0,0 @@ -/** - * External dependencies - */ -import { useAnalytics } from '@automattic/jetpack-shared-extension-utils'; -import { useState, useCallback } from '@wordpress/element'; -/** - * Internal dependencies - */ -import { installAndActivatePlugin, activatePlugin } from '../../../util/plugin-management.js'; - -type PluginInstallation = { - isInstalling: boolean; - installPlugin: () => Promise< boolean >; -}; - -/** - * Custom hook to handle plugin installation and activation flows. - * - * @param {string} slug - The plugin slug (e.g., 'akismet') - * @param {string} pluginPath - The plugin path (e.g., 'akismet/akismet') - * @param {boolean} isInstalled - Whether the plugin is installed - * @param {string} tracksEventName - The name of the tracks event to record - * @return {object} Plugin installation states and handlers - */ -export const usePluginInstallation = ( - slug: string, - pluginPath: string, - isInstalled: boolean, - tracksEventName: string -): PluginInstallation => { - const [ isInstalling, setIsInstalling ] = useState( false ); - const { tracks } = useAnalytics(); - - const installPlugin = useCallback( async () => { - setIsInstalling( true ); - - if ( tracksEventName ) { - tracks.recordEvent( tracksEventName, { - screen: 'block-editor', - intent: isInstalled ? 'activate-plugin' : 'install-plugin', - } ); - } - - try { - if ( isInstalled ) { - await activatePlugin( pluginPath ); - } else { - await installAndActivatePlugin( slug ); - } - return true; - } catch { - // Let the component handle the error state - return false; - } finally { - setIsInstalling( false ); - } - }, [ slug, pluginPath, isInstalled, tracks, tracksEventName ] ); - - return { - isInstalling, - installPlugin, - }; -}; diff --git a/projects/packages/forms/src/blocks/contact-form/components/jetpack-integrations-modal/integration-card/plugin-action-button.tsx b/projects/packages/forms/src/blocks/contact-form/components/jetpack-integrations-modal/integration-card/plugin-action-button.tsx index 362ee8bd317c7..bee3a55202200 100644 --- a/projects/packages/forms/src/blocks/contact-form/components/jetpack-integrations-modal/integration-card/plugin-action-button.tsx +++ b/projects/packages/forms/src/blocks/contact-form/components/jetpack-integrations-modal/integration-card/plugin-action-button.tsx @@ -7,8 +7,7 @@ import { __ } from '@wordpress/i18n'; /** * Internal dependencies */ -import useConfigValue from '../../../../../hooks/use-config-value.ts'; -import { usePluginInstallation } from '../hooks/use-plugin-installation.ts'; +import { usePluginInstallation } from '../../../../../hooks/use-plugin-installation.ts'; type PluginActionButtonProps = { slug: string; @@ -27,18 +26,20 @@ const PluginActionButton = ( { refreshStatus, trackEventName, }: PluginActionButtonProps ) => { - const { isInstalling, installPlugin } = usePluginInstallation( - slug, - pluginFile, - isInstalled, - trackEventName - ); + const trackEventProps = { + screen: 'block-editor', + }; - // Permissions from consolidated Forms config (shared across editor and dashboard) - const canUserInstallPlugins = useConfigValue( 'canInstallPlugins' ); - const canUserActivatePlugins = useConfigValue( 'canActivatePlugins' ); + const { isInstalling, installPlugin, canInstallPlugins, canActivatePlugins } = + usePluginInstallation( { + slug, + pluginPath: pluginFile, + isInstalled, + trackEventName, + trackEventProps, + } ); - const canPerformAction = isInstalled ? canUserActivatePlugins : canUserInstallPlugins; + const canPerformAction = isInstalled ? canActivatePlugins : canInstallPlugins; const [ isReconcilingStatus, setIsReconcilingStatus ] = useState( false ); const isDisabled = isInstalling || isReconcilingStatus || ! canPerformAction; @@ -84,10 +85,10 @@ const PluginActionButton = ( { ); const getTooltipText = (): string => { - if ( isInstalled && ! canUserActivatePlugins ) { + if ( isInstalled && ! canActivatePlugins ) { return tooltipTextNoActivatePerms; } - if ( ! isInstalled && ! canUserInstallPlugins ) { + if ( ! isInstalled && ! canInstallPlugins ) { return tooltipTextNoInstallPerms; } return String( isInstalled ? tooltipTextActivate : tooltipTextInstall ); diff --git a/projects/packages/forms/src/dashboard/components/empty-responses/index.tsx b/projects/packages/forms/src/dashboard/components/empty-responses/index.tsx index 7ca6578a8d50e..dafa76f4f67bd 100644 --- a/projects/packages/forms/src/dashboard/components/empty-responses/index.tsx +++ b/projects/packages/forms/src/dashboard/components/empty-responses/index.tsx @@ -1,12 +1,157 @@ +/** + * External dependencies + */ +import { isSimpleSite } from '@automattic/jetpack-script-data'; import { + Button, + ExternalLink, __experimentalText as Text, // eslint-disable-line @wordpress/no-unsafe-wp-apis __experimentalVStack as VStack, // eslint-disable-line @wordpress/no-unsafe-wp-apis } from '@wordpress/components'; +import { useSelect, useDispatch } from '@wordpress/data'; +import { createInterpolateElement, useCallback, useMemo } from '@wordpress/element'; import { __, _n, sprintf } from '@wordpress/i18n'; +/** + * Internal dependencies + */ import useConfigValue from '../../../hooks/use-config-value.ts'; +import { usePluginInstallation } from '../../../hooks/use-plugin-installation.ts'; +import { INTEGRATIONS_STORE } from '../../../store/integrations/index.ts'; import CreateFormButton from '../create-form-button/index.tsx'; +/** + * Types + */ +import type { + IntegrationsDispatch, + SelectIntegrations, +} from '../../../store/integrations/index.ts'; +import type { Integration } from '../../../types/index.ts'; +import type { ReactNode } from 'react'; -const EmptyWrapper = ( { heading = '', body = '', actions = null } ) => ( +type UseInstallAkismetReturn = { + shouldShowAkismetCta: boolean; + wrapperBody: ReactNode; + isInstallingAkismet: boolean; + canPerformAkismetAction: boolean; + wrapperButtonText: string; + handleAkismetSetup: () => Promise< void >; +}; + +type EmptyResponsesProps = { + status: string; + isSearch: boolean; + readStatusFilter?: 'unread' | 'read'; +}; + +type EmptyWrapperProps = { + heading?: string; + body?: string | ReactNode; + actions?: ReactNode; +}; + +/** + * Hook to handle Akismet installation and activation. + * + * @return {UseInstallAkismetReturn} An object containing the necessary data and functions to handle Akismet installation and activation. + */ +const useInstallAkismet = (): UseInstallAkismetReturn => { + const { akismetIntegration } = useSelect( ( select: SelectIntegrations ) => { + const store = select( INTEGRATIONS_STORE ); + const integrations = store.getIntegrations() || []; + + return { + akismetIntegration: integrations.find( + ( integration: Integration ) => integration.id === 'akismet' + ), + }; + }, [] ) as { akismetIntegration?: Integration }; + + const { refreshIntegrations } = useDispatch( INTEGRATIONS_STORE ) as IntegrationsDispatch; + + const akismetIntegrationReady = useMemo( + () => !! akismetIntegration && ! akismetIntegration.__isPartial, + [ akismetIntegration ] + ); + + const isInstalled = !! akismetIntegration?.isInstalled; + + const isAkismetActive = akismetIntegrationReady && isInstalled && !! akismetIntegration?.isActive; + + const shouldShowAkismetCta = akismetIntegrationReady && ! isAkismetActive && ! isSimpleSite(); + + const akismetPluginFile = useMemo( + () => akismetIntegration?.pluginFile ?? 'akismet/akismet', + [ akismetIntegration?.pluginFile ] + ); + + const wrapperBody: ReactNode = createInterpolateElement( + __( + 'Want automatic spam filtering? Akismet Anti-spam protects millions of sites. Learn more.', + 'jetpack-forms' + ), + { + moreInfoLink: , + } + ); + + const activateButtonText = __( 'Activate Akismet Anti-spam', 'jetpack-forms' ); + const installAndActivateButtonText = __( 'Install Akismet Anti-spam', 'jetpack-forms' ); + const wrapperButtonText = isInstalled ? activateButtonText : installAndActivateButtonText; + + const { + isInstalling: isInstallingAkismet, + installPlugin, + canInstallPlugins, + canActivatePlugins, + } = usePluginInstallation( { + slug: 'akismet', + pluginPath: akismetPluginFile, + isInstalled, + onSuccess: refreshIntegrations, + trackEventName: 'jetpack_forms_upsell_akismet_click', + trackEventProps: { + screen: 'dashboard', + }, + successNotices: { + install: { + message: __( 'Akismet installed and activated.', 'jetpack-forms' ), + options: { type: 'snackbar', id: 'akismet-install-success' }, + }, + activate: { + message: __( 'Akismet activated.', 'jetpack-forms' ), + options: { type: 'snackbar', id: 'akismet-install-success' }, + }, + }, + errorNotice: { + message: __( 'Could not set up Akismet. Please try again.', 'jetpack-forms' ), + options: { type: 'snackbar', id: 'akismet-install-error' }, + }, + } ); + + const canPerformAkismetAction = + isInstalled && akismetIntegrationReady + ? canActivatePlugins !== false + : canInstallPlugins !== false; + + const handleAkismetSetup = useCallback( async () => { + if ( isInstallingAkismet || ! akismetIntegrationReady || ! canPerformAkismetAction ) { + return; + } + + await installPlugin(); + }, [ isInstallingAkismet, akismetIntegrationReady, canPerformAkismetAction, installPlugin ] ); + + return { + shouldShowAkismetCta, + wrapperBody, + isInstallingAkismet, + canPerformAkismetAction, + wrapperButtonText, + handleAkismetSetup, + }; +}; + +const EmptyWrapper = ( { heading = '', body = '', actions = null }: EmptyWrapperProps ) => ( { heading && ( @@ -18,14 +163,16 @@ const EmptyWrapper = ( { heading = '', body = '', actions = null } ) => ( ); -type EmptyResponsesProps = { - status: string; - isSearch: boolean; - readStatusFilter?: 'unread' | 'read'; -}; - const EmptyResponses = ( { status, isSearch, readStatusFilter }: EmptyResponsesProps ) => { const emptyTrashDays = useConfigValue( 'emptyTrashDays' ) ?? 0; + const { + shouldShowAkismetCta, + wrapperBody, + isInstallingAkismet, + canPerformAkismetAction, + wrapperButtonText, + handleAkismetSetup, + } = useInstallAkismet(); // Handle search and filter states first const hasReadStatusFilter = !! readStatusFilter; @@ -60,7 +207,28 @@ const EmptyResponses = ( { status, isSearch, readStatusFilter }: EmptyResponsesP 'Spam responses are permanently deleted after 15 days.', 'jetpack-forms' ); + if ( status === 'spam' ) { + if ( shouldShowAkismetCta ) { + return ( + + { wrapperButtonText } + + } + /> + ); + } + return ; } diff --git a/projects/packages/forms/src/dashboard/inbox/stage/index.js b/projects/packages/forms/src/dashboard/inbox/stage/index.js index 05248db7bc111..044617c6d45c6 100644 --- a/projects/packages/forms/src/dashboard/inbox/stage/index.js +++ b/projects/packages/forms/src/dashboard/inbox/stage/index.js @@ -3,9 +3,11 @@ */ import jetpackAnalytics from '@automattic/jetpack-analytics'; import { JetpackLogo } from '@automattic/jetpack-components'; +import { isSimpleSite } from '@automattic/jetpack-script-data'; import { Badge } from '@automattic/ui'; import { ExternalLink, Modal } from '@wordpress/components'; import { useResizeObserver, useViewportMatch } from '@wordpress/compose'; +import { useSelect } from '@wordpress/data'; import { DataViews } from '@wordpress/dataviews/wp'; import { dateI18n, getSettings as getDateSettings } from '@wordpress/date'; import { useCallback, useMemo, useState } from '@wordpress/element'; @@ -19,6 +21,7 @@ import { useSearchParams } from 'react-router'; * Internal dependencies */ import useConfigValue from '../../../hooks/use-config-value.ts'; +import { INTEGRATIONS_STORE } from '../../../store/integrations/index.ts'; import CreateFormButton from '../../components/create-form-button/index.tsx'; import EmptyResponses from '../../components/empty-responses/index.tsx'; import EmptySpamButton from '../../components/empty-spam-button/index.tsx'; @@ -137,6 +140,23 @@ export default function InboxView() { totalItems, totalPages, } = useInboxData(); + const isAkismetStatusPending = useSelect( + select => { + const store = select( INTEGRATIONS_STORE ); + const integrations = store.getIntegrations() || []; + const isIntegrationsLoading = store.isIntegrationsLoading(); + const akismetIntegration = integrations.find( integration => integration.id === 'akismet' ); + + return ( + statusFilter === 'spam' && + ! isSimpleSite() && + ( isIntegrationsLoading || ! akismetIntegration || akismetIntegration.__isPartial ) + ); + }, + [ statusFilter ] + ); + + const isInboxLoading = isLoadingData || isAkismetStatusPending; useEffect( () => { const _filters = view.filters?.reduce( ( accumulator, { field, value } ) => { @@ -497,7 +517,7 @@ export default function InboxView() { fields={ fields } actions={ actions } data={ records || EMPTY_ARRAY } - isLoading={ isLoadingData } + isLoading={ isInboxLoading } view={ view } onChangeView={ setView } selection={ selection } diff --git a/projects/packages/forms/src/hooks/use-plugin-installation.ts b/projects/packages/forms/src/hooks/use-plugin-installation.ts new file mode 100644 index 0000000000000..97dfaade677b5 --- /dev/null +++ b/projects/packages/forms/src/hooks/use-plugin-installation.ts @@ -0,0 +1,142 @@ +/** + * External dependencies + */ +import { useAnalytics } from '@automattic/jetpack-shared-extension-utils'; +import { useDispatch } from '@wordpress/data'; +import { useState, useCallback } from '@wordpress/element'; +import { store as noticesStore } from '@wordpress/notices'; +/** + * Internal dependencies + */ +import { + installAndActivatePlugin, + activatePlugin, +} from '../blocks/contact-form/util/plugin-management.js'; +import useConfigValue from './use-config-value.ts'; + +type NoticeOptions = Record< string, unknown >; + +type NoticeConfig = { + message?: string; + options?: NoticeOptions; +}; + +type SuccessNotices = { + install?: NoticeConfig; + activate?: NoticeConfig; +}; + +type UsePluginInstallationArgs = { + slug: string; + pluginPath: string; + isInstalled: boolean; + trackEventName?: string; + trackEventProps?: Record< string, unknown >; + onSuccess?: () => void | Promise< void >; + successNotices?: SuccessNotices; + errorNotice?: NoticeConfig; +}; + +type PluginInstallation = { + isInstalling: boolean; + installPlugin: () => Promise< boolean >; + canInstallPlugins: boolean; + canActivatePlugins: boolean; +}; + +/** + * Custom hook to handle plugin installation and activation flows. + * + * @param {UsePluginInstallationArgs} args - Hook arguments. + * @return {PluginInstallation} Plugin installation states and handlers. + */ +export const usePluginInstallation = ( { + slug, + pluginPath, + isInstalled, + trackEventName, + trackEventProps = {}, + onSuccess, + successNotices, + errorNotice, +}: UsePluginInstallationArgs ): PluginInstallation => { + const [ isInstalling, setIsInstalling ] = useState( false ); + const { tracks } = useAnalytics(); + const { createSuccessNotice, createErrorNotice } = useDispatch( noticesStore ); + const canInstallPlugins = useConfigValue( 'canInstallPlugins' ); + const canActivatePlugins = useConfigValue( 'canActivatePlugins' ); + + const installPlugin = useCallback( async () => { + setIsInstalling( true ); + + if ( trackEventName ) { + tracks.recordEvent( trackEventName, { + intent: isInstalled ? 'activate-plugin' : 'install-plugin', + ...( trackEventProps ?? {} ), + } ); + } + + try { + if ( isInstalled ) { + if ( ! canActivatePlugins ) { + return false; + } + + await activatePlugin( pluginPath ); + } else { + if ( ! canInstallPlugins ) { + return false; + } + + await installAndActivatePlugin( slug ); + } + + const successNoticeConfig = isInstalled ? successNotices?.activate : successNotices?.install; + + if ( successNoticeConfig?.message ) { + createSuccessNotice( successNoticeConfig.message, successNoticeConfig.options ); + } + + if ( onSuccess ) { + await onSuccess(); + } + + return true; + } catch ( error ) { + if ( errorNotice ) { + const noticeMessage = + errorNotice.message || ( error instanceof Error ? error.message : undefined ); + + if ( noticeMessage ) { + createErrorNotice( noticeMessage, errorNotice.options ); + } + } + + return false; + } finally { + setIsInstalling( false ); + } + }, [ + trackEventName, + tracks, + isInstalled, + trackEventProps, + successNotices?.activate, + successNotices?.install, + onSuccess, + canActivatePlugins, + pluginPath, + canInstallPlugins, + slug, + createSuccessNotice, + errorNotice, + createErrorNotice, + ] ); + + return { + isInstalling, + installPlugin, + canInstallPlugins, + canActivatePlugins, + }; +};