diff --git a/mailpoet/assets/js/src/mailpoet-email-editor-integration/index.ts b/mailpoet/assets/js/src/mailpoet-email-editor-integration/index.ts index efc66e690a..146f45b8d6 100644 --- a/mailpoet/assets/js/src/mailpoet-email-editor-integration/index.ts +++ b/mailpoet/assets/js/src/mailpoet-email-editor-integration/index.ts @@ -6,11 +6,18 @@ import { addFilter, addAction } from '@wordpress/hooks'; import { MailPoet } from 'mailpoet'; import { withSatismeterSurvey } from './satismeter-survey'; import './index.scss'; +import { ValidateEmailContent } from './validate-email-content'; addFilter('mailpoet_email_editor_wrap_editor_component', 'mailpoet', (editor) => withSatismeterSurvey(editor), ); +addFilter( + 'mailpoet_email_editor_content_validation_rules', + 'mailpoet', + (validationRules: []) => [...validationRules, ...ValidateEmailContent()], +); + const EVENTS_TO_TRACK = [ 'email_editor_events_editor_layout_loaded', // email editor was opened 'email_editor_events_template_select_modal_template_selected', // a template was selected from the template-select modal diff --git a/mailpoet/assets/js/src/mailpoet-email-editor-integration/validate-email-content.ts b/mailpoet/assets/js/src/mailpoet-email-editor-integration/validate-email-content.ts new file mode 100644 index 0000000000..0bd644d19e --- /dev/null +++ b/mailpoet/assets/js/src/mailpoet-email-editor-integration/validate-email-content.ts @@ -0,0 +1,93 @@ +import { useMemo } from '@wordpress/element'; +import { createBlock } from '@wordpress/blocks'; +import { __ } from '@wordpress/i18n'; +import { dispatch, useSelect } from '@wordpress/data'; +import { store as blockEditorStore } from '@wordpress/block-editor'; +import { store as coreDataStore } from '@wordpress/core-data'; + +const emailEditorStore = 'email-editor/editor'; + +const contentLink = `${__( + 'Unsubscribe', + 'mailpoet', +)} | ${__( + 'Manage subscription', + 'mailpoet', +)}`; + +export function ValidateEmailContent() { + const { contentBlockId, hasFooter } = useSelect((select) => { + const allBlocks = select(blockEditorStore).getBlocks(); + const noBodyBlocks = allBlocks.filter( + (block) => + block.name !== 'mailpoet/powered-by-mailpoet' && + block.name !== 'core/post-content', + ); + // @ts-expect-error getBlocksByName is not defined in types + const blocks = select(blockEditorStore).getBlocksByName( + 'core/post-content', + ) as string[] | undefined; + return { + contentBlockId: blocks?.[0], + hasFooter: noBodyBlocks.length > 0, + }; + }); + + /* eslint-disable @typescript-eslint/ban-ts-comment */ + const { editedTemplateContent, postTemplateId } = useSelect((mapSelect) => ({ + editedTemplateContent: + // @ts-ignore + mapSelect(emailEditorStore).getCurrentTemplateContent() as string, + postTemplateId: + // @ts-ignore + mapSelect(emailEditorStore).getCurrentTemplate()?.id as string, + })); + + return useMemo(() => { + const linksParagraphBlock = createBlock('core/paragraph', { + align: 'center', + fontSize: 'small', + content: contentLink, + }); + + return [ + { + id: 'missing-unsubscribe-link', + test: (emailContent: string) => + !emailContent.includes('[mailpoet/subscription-unsubscribe-url]'), + message: __( + 'All emails must include an "Unsubscribe" link.', + 'mailpoet', + ), + actions: [ + { + label: __('Insert link', 'mailpoet'), + onClick: () => { + if (!hasFooter) { + void dispatch(blockEditorStore).insertBlock( + linksParagraphBlock, + undefined, + contentBlockId, + ); + } else { + void dispatch(coreDataStore).editEntityRecord( + 'postType', + 'wp_template', + postTemplateId, + { + content: ` + ${editedTemplateContent} + + ${contentLink} + + `, + }, + ); + } + }, + }, + ], + }, + ]; + }, [contentBlockId, postTemplateId, hasFooter, editedTemplateContent]); +} diff --git a/packages/js/email-editor/src/hooks/use-content-validation.ts b/packages/js/email-editor/src/hooks/use-content-validation.ts index d604776185..5d06341c31 100644 --- a/packages/js/email-editor/src/hooks/use-content-validation.ts +++ b/packages/js/email-editor/src/hooks/use-content-validation.ts @@ -1,123 +1,50 @@ /** * External dependencies */ -import { __ } from '@wordpress/i18n'; -import { useCallback, useMemo } from '@wordpress/element'; -import { dispatch, useSelect, subscribe } from '@wordpress/data'; -import { store as blockEditorStore } from '@wordpress/block-editor'; -import { createBlock } from '@wordpress/blocks'; -import { store as coreDataStore } from '@wordpress/core-data'; +import { useCallback } from '@wordpress/element'; +import { useSelect, subscribe } from '@wordpress/data'; +import { applyFilters } from '@wordpress/hooks'; /** * Internal dependencies */ -import { storeName as emailEditorStore } from '../store'; +import { + EmailContentValidationRule, + storeName as emailEditorStore, +} from '../store'; import { useShallowEqual } from './use-shallow-equal'; import { useValidationNotices } from './use-validation-notices'; +// Shared reference to an empty array for cases where it is important to avoid +// returning a new array reference on every invocation +const EMPTY_ARRAY = []; + export type ContentValidationData = { isInvalid: boolean; validateContent: () => boolean; }; export const useContentValidation = (): ContentValidationData => { - const { contentBlockId, hasFooter } = useSelect( ( select ) => { - const allBlocks = select( blockEditorStore ).getBlocks(); - const noBodyBlocks = allBlocks.filter( - ( block ) => - block.name !== 'mailpoet/powered-by-mailpoet' && - block.name !== 'core/post-content' - ); - // @ts-expect-error getBlocksByName is not defined in types - const blocks = select( blockEditorStore ).getBlocksByName( - 'core/post-content' - ) as string[] | undefined; - return { - contentBlockId: blocks?.[ 0 ], - hasFooter: noBodyBlocks.length > 0, - }; - } ); - const { addValidationNotice, hasValidationNotice, removeValidationNotice } = useValidationNotices(); - const { editedContent, editedTemplateContent, postTemplateId } = useSelect( + + const { editedContent, editedTemplateContent } = useSelect( ( mapSelect ) => ( { editedContent: mapSelect( emailEditorStore ).getEditedEmailContent(), editedTemplateContent: mapSelect( emailEditorStore ).getCurrentTemplateContent(), - postTemplateId: - mapSelect( emailEditorStore ).getCurrentTemplate()?.id, } ) ); + const rules: EmailContentValidationRule[] = applyFilters( + 'mailpoet_email_editor_content_validation_rules', + EMPTY_ARRAY + ) as EmailContentValidationRule[]; + const content = useShallowEqual( editedContent ); const templateContent = useShallowEqual( editedTemplateContent ); - const contentLink = `${ __( - 'Unsubscribe', - 'mailpoet' - ) } | ${ __( - 'Manage subscription', - 'mailpoet' - ) }`; - - const rules = useMemo( () => { - const linksParagraphBlock = createBlock( 'core/paragraph', { - align: 'center', - fontSize: 'small', - content: contentLink, - } ); - - return [ - { - id: 'missing-unsubscribe-link', - test: ( emailContent ) => - ! emailContent.includes( - '[mailpoet/subscription-unsubscribe-url]' - ), - message: __( - 'All emails must include an "Unsubscribe" link.', - 'mailpoet' - ), - actions: [ - { - label: __( 'Insert link', 'mailpoet' ), - onClick: () => { - if ( ! hasFooter ) { - void dispatch( blockEditorStore ).insertBlock( - linksParagraphBlock, - undefined, - contentBlockId - ); - } else { - void dispatch( coreDataStore ).editEntityRecord( - 'postType', - 'wp_template', - postTemplateId, - { - content: ` - ${ editedTemplateContent } - - ${ contentLink } - - `, - } - ); - } - }, - }, - ], - }, - ]; - }, [ - contentBlockId, - hasFooter, - contentLink, - postTemplateId, - editedTemplateContent, - ] ); - const validateContent = useCallback( (): boolean => { let isValid = true; rules.forEach( ( { id, test, message, actions } ) => { diff --git a/packages/js/email-editor/src/store/types.ts b/packages/js/email-editor/src/store/types.ts index a2d48cb421..269eb2bdc0 100644 --- a/packages/js/email-editor/src/store/types.ts +++ b/packages/js/email-editor/src/store/types.ts @@ -269,3 +269,15 @@ export type EmailEditorPostType = Omit< Post, 'type' > & { type: string; mailpoet_data?: MailPoetEmailPostContentExtended; }; + +export type EmailContentValidationAction = { + label: string; + onClick: () => void; +}; + +export type EmailContentValidationRule = { + id: string; + test: ( emailContent: string ) => boolean; + message: string; + actions: EmailContentValidationAction[]; +};