diff --git a/includes/Experiments/Editorial_Notes/Editorial_Notes.php b/includes/Experiments/Editorial_Notes/Editorial_Notes.php index 813d8477e..52eb25aed 100644 --- a/includes/Experiments/Editorial_Notes/Editorial_Notes.php +++ b/includes/Experiments/Editorial_Notes/Editorial_Notes.php @@ -124,11 +124,22 @@ public function maybe_set_ai_author( $prepared_comment, \WP_REST_Request $reques */ public function enqueue_assets(): void { Asset_Loader::enqueue_script( 'editorial_notes', 'experiments/editorial-notes', array( 'include_core_abilities' => true ) ); + + /** + * Filters the minimum content length required to enable Editorial notes. + * + * @since x.x.x + * + * @param int $min_content_length The minimum number of characters required. Default 100. + */ + $min_content_length = (int) apply_filters( 'wpai_editorial_notes_min_content_length', 100 ); + Asset_Loader::localize_script( 'editorial_notes', 'EditorialNotesData', array( - 'enabled' => $this->is_enabled(), + 'enabled' => $this->is_enabled(), + 'minContentLength' => $min_content_length, ) ); } diff --git a/src/experiments/editorial-notes/components/EditorialNotesPlugin.tsx b/src/experiments/editorial-notes/components/EditorialNotesPlugin.tsx index a9a421659..bd64ffb14 100644 --- a/src/experiments/editorial-notes/components/EditorialNotesPlugin.tsx +++ b/src/experiments/editorial-notes/components/EditorialNotesPlugin.tsx @@ -40,9 +40,20 @@ import { * reviewable blocks. */ export default function EditorialNotesPlugin() { - const { isReviewing, progress, total, lastRunCount, runReview } = - useEditorialNotes(); - const { isReviewing: isReviewingBlock, reviewBlock } = useEditorialBlock(); + const { + isReviewing, + progress, + total, + lastRunCount, + runReview, + isContentTooShort, + minContentLength, + } = useEditorialNotes(); + const { + isReviewing: isReviewingBlock, + reviewBlock, + isContentTooShort: isBlockReviewDisabled, + } = useEditorialBlock(); const { openGeneralSidebar } = useDispatch( editPostStore ); const openNotesPanel = () => openGeneralSidebar?.( 'edit-post/collab-sidebar' ); @@ -65,10 +76,19 @@ export default function EditorialNotesPlugin() { const buttonLabel = isReviewing ? reviewingLabel : __( 'Generate Editorial Notes', 'ai' ); - const buttonDescription = __( - 'This analyzes the content of this post block-by-block and adds editorial Notes with suggestions on each block.', - 'ai' - ); + const buttonDescription = isContentTooShort + ? sprintf( + /* translators: %d: minimum number of characters required. */ + __( + 'Editorial Notes will be available when the post content has at least %d characters.', + 'ai' + ), + minContentLength + ) + : __( + 'This analyzes the content of this post block-by-block and adds editorial Notes with suggestions on each block.', + 'ai' + ); return ( <> @@ -80,7 +100,7 @@ export default function EditorialNotesPlugin() { icon={ commentContent } onClick={ runReview } isBusy={ isReviewing } - disabled={ isReviewing } + disabled={ isReviewing || isContentTooShort } style={ { justifyContent: 'center', width: '100%', @@ -148,7 +168,9 @@ export default function EditorialNotesPlugin() { icon={ isReviewingBlock ? : commentContent } - disabled={ isReviewingBlock } + disabled={ + isReviewingBlock || isBlockReviewDisabled + } onClick={ () => { if ( clientId ) { reviewBlock( clientId ); diff --git a/src/experiments/editorial-notes/hooks/useEditorialNotes.ts b/src/experiments/editorial-notes/hooks/useEditorialNotes.ts index c9450a4b3..3c8db15f2 100644 --- a/src/experiments/editorial-notes/hooks/useEditorialNotes.ts +++ b/src/experiments/editorial-notes/hooks/useEditorialNotes.ts @@ -5,7 +5,7 @@ /** * WordPress dependencies */ -import { dispatch, select } from '@wordpress/data'; +import { dispatch, select, useSelect } from '@wordpress/data'; import { store as blockEditorStore } from '@wordpress/block-editor'; import { store as coreStore } from '@wordpress/core-data'; import { store as editPostStore } from '@wordpress/edit-post'; @@ -13,6 +13,7 @@ import { store as editorStore } from '@wordpress/editor'; import { useState } from '@wordpress/element'; import { __, _n, sprintf } from '@wordpress/i18n'; import { store as noticesStore } from '@wordpress/notices'; +import { count } from '@wordpress/wordcount'; /** * Internal dependencies @@ -70,6 +71,32 @@ interface NoteRecord { [ key: string ]: unknown; } +/** + * Hook for determining whether Review Notes should be available for the + * current post content. + * + * @return Availability state for the Review Notes feature. + */ +function useReviewNotesAvailability(): { + content: string; + minContentLength: number; + isContentTooShort: boolean; +} { + const content = + useSelect( ( selectStore ) => { + return ( selectStore( editorStore ) as any ).getEditedPostContent(); + }, [] ) ?? ''; + const minContentLength: number = + ( window as any ).aiReviewNotesData?.minContentLength ?? 100; + + return { + content, + minContentLength, + isContentTooShort: + count( content, 'characters_including_spaces' ) < minContentLength, + }; +} + /** * Reviews a single block and creates/updates a Note if suggestions are found. * @@ -165,18 +192,26 @@ export function useEditorialNotes(): { progress: number; total: number; lastRunCount: number | null; + isContentTooShort: boolean; + minContentLength: number; runReview: () => Promise< void >; } { const [ isReviewing, setIsReviewing ] = useState< boolean >( false ); const [ progress, setProgress ] = useState< number >( 0 ); const [ total, setTotal ] = useState< number >( 0 ); const [ lastRunCount, setLastRunCount ] = useState< number | null >( null ); + const { content, isContentTooShort, minContentLength } = + useReviewNotesAvailability(); const runReview = async () => { if ( ! ensureProvider( NOTICE_ID ) ) { return; } + if ( isContentTooShort ) { + return; + } + setIsReviewing( true ); setProgress( 0 ); setTotal( 0 ); @@ -188,9 +223,6 @@ export function useEditorialNotes(): { const postId = ( select( editorStore ) as any ).getCurrentPostId() as number; - const content = ( - select( editorStore ) as any - ).getEditedPostContent() as string; // Get all blocks and flatten the tree. const allBlocks = ( @@ -279,7 +311,15 @@ export function useEditorialNotes(): { } }; - return { isReviewing, progress, total, lastRunCount, runReview }; + return { + isReviewing, + progress, + total, + lastRunCount, + isContentTooShort, + minContentLength, + runReview, + }; } /** @@ -289,11 +329,17 @@ export function useEditorialNotes(): { */ export function useEditorialBlock(): { isReviewing: boolean; + isContentTooShort: boolean; reviewBlock: ( clientId: string ) => Promise< void >; } { const [ isReviewing, setIsReviewing ] = useState< boolean >( false ); + const { content, isContentTooShort } = useReviewNotesAvailability(); const reviewBlock = async ( clientId: string ) => { + if ( isContentTooShort ) { + return; + } + setIsReviewing( true ); ( dispatch( noticesStore ) as any ).removeNotice( @@ -312,9 +358,6 @@ export function useEditorialBlock(): { const postId = ( select( editorStore ) as any ).getCurrentPostId() as number; - const content = ( - select( editorStore ) as any - ).getEditedPostContent() as string; // Fetch fresh note state for this invocation. const [ pendingNotes, approvedNotes ] = await Promise.all( [ @@ -379,7 +422,7 @@ export function useEditorialBlock(): { } }; - return { isReviewing, reviewBlock }; + return { isReviewing, isContentTooShort, reviewBlock }; } /** diff --git a/tests/Integration/Includes/Experiments/Editorial_Notes/Editorial_NotesTest.php b/tests/Integration/Includes/Experiments/Editorial_Notes/Editorial_NotesTest.php index 5354f01e7..02d82c308 100644 --- a/tests/Integration/Includes/Experiments/Editorial_Notes/Editorial_NotesTest.php +++ b/tests/Integration/Includes/Experiments/Editorial_Notes/Editorial_NotesTest.php @@ -126,6 +126,46 @@ public function test_ai_note_comment_meta_is_registered() { $this->assertTrue( $registered['ai_note']['show_in_rest'], 'ai_note meta should have show_in_rest enabled' ); } + /** + * Tests that enqueue_assets() localizes the default minimum content length. + * + * @since x.x.x + */ + public function test_enqueue_assets_localizes_default_min_content_length() { + $GLOBALS['wp_scripts'] = new \WP_Scripts(); + + $this->experiment->enqueue_assets(); + + $this->assertTrue( wp_script_is( 'ai_editorial_notes', 'enqueued' ) ); + $this->assertStringContainsString( + '"minContentLength":"100"', + (string) wp_scripts()->get_data( 'ai_editorial_notes', 'data' ) + ); + } + + /** + * Tests that enqueue_assets() localizes the filtered minimum content length. + * + * @since x.x.x + */ + public function test_enqueue_assets_localizes_filtered_min_content_length() { + $filter = static function () { + return 250; + }; + + add_filter( 'wpai_editorial_notes_min_content_length', $filter ); + $GLOBALS['wp_scripts'] = new \WP_Scripts(); + + $this->experiment->enqueue_assets(); + + remove_filter( 'wpai_editorial_notes_min_content_length', $filter ); + + $this->assertStringContainsString( + '"minContentLength":"250"', + (string) wp_scripts()->get_data( 'ai_editorial_notes', 'data' ) + ); + } + // ------------------------------------------------------------------------- // maybe_set_ai_author() // ------------------------------------------------------------------------- diff --git a/tests/e2e/specs/experiments/editorial-notes.spec.js b/tests/e2e/specs/experiments/editorial-notes.spec.js index 01e867a62..8aa705415 100644 --- a/tests/e2e/specs/experiments/editorial-notes.spec.js +++ b/tests/e2e/specs/experiments/editorial-notes.spec.js @@ -40,7 +40,59 @@ test.describe( 'AI Editorial Notes Experiment', () => { ).toBeVisible(); } ); - test( 'Shows the "Review with AI" button in the block toolbar', async ( { + test( 'Disables Editorial Notes until the post content reaches the minimum length', async ( { + admin, + editor, + page, + } ) => { + await admin.createNewPost( { + title: 'Short Editorial Notes Test', + content: 'Too short.', + } ); + + await editor.openDocumentSettingsSidebar(); + + const reviewButton = page.getByRole( 'button', { + name: 'Generate Editorial Notes', + } ); + await expect( reviewButton ).toBeVisible(); + await expect( reviewButton ).toBeDisabled(); + + await expect( + page.locator( '.description', { + hasText: + 'Editorial Notes will be available when the post content has at least 100 characters.', + } ) + ).toBeVisible(); + } ); + + test( 'Enables Editorial Notes once the post content meets the minimum length', async ( { + admin, + editor, + page, + } ) => { + await admin.createNewPost( { + title: 'Long Editorial Notes Test', + content: + 'This paragraph contains enough content for the Editorial Notes feature to become available and analyze the post block-by-block.', + } ); + + await editor.openDocumentSettingsSidebar(); + + const reviewButton = page.getByRole( 'button', { + name: 'Generate Editorial Notes', + } ); + await expect( reviewButton ).toBeVisible(); + await expect( reviewButton ).toBeEnabled(); + + await expect( + page.locator( '.description', { + hasText: 'at least 100 characters', + } ) + ).toHaveCount( 0 ); + } ); + + test( 'Shows the "Generate Editorial Note" button in the block toolbar', async ( { admin, editor, page, @@ -67,6 +119,29 @@ test.describe( 'AI Editorial Notes Experiment', () => { ).toBeVisible(); } ); + test( 'Disables single-block Editorial Notes when the post content is shorter than the minimum length', async ( { + admin, + editor, + page, + } ) => { + await admin.createNewPost( { + title: 'Short Single Block Review Test', + } ); + + await editor.insertBlock( { + name: 'core/paragraph', + attributes: { + content: 'Tiny text.', + }, + } ); + + await editor.clickBlockToolbarButton( 'Options' ); + + await expect( + page.getByRole( 'menuitem', { name: 'Generate Editorial Note' } ) + ).toBeDisabled(); + } ); + test( 'Shows suggestion count after a successful review', async ( { admin, editor, @@ -133,12 +208,12 @@ test.describe( 'AI Editorial Notes Experiment', () => { ).toBeVisible(); } ); - test( 'Does nothing when post has no reviewable blocks', async ( { + test( 'Disables Editorial Notes when post content is below the minimum length', async ( { admin, editor, page, } ) => { - // Create a post with no content blocks. + // Create a post with content below the minimum threshold. await admin.createNewPost( { title: 'Empty Post Test' } ); // Ensure the sidebar is visible. @@ -148,15 +223,14 @@ test.describe( 'AI Editorial Notes Experiment', () => { name: 'Generate Editorial Notes', } ); await expect( reviewButton ).toBeVisible(); + await expect( reviewButton ).toBeDisabled(); - await reviewButton.click(); - - // Button should remain enabled immediately (no blocks to process). - await expect( reviewButton ).toBeEnabled(); - - // "No new suggestions found" should appear. + // The descriptive text should explain when the button becomes available. await expect( - page.locator( '.description', { hasText: 'No new suggestions' } ) + page.locator( '.description', { + hasText: + 'Editorial Notes will be available when the post content has at least 100 characters.', + } ) ).toBeVisible(); } );