From 40dafbf3c902077992095f29cd0c7aadbc5b310b Mon Sep 17 00:00:00 2001 From: Gautam Mehta Date: Wed, 13 May 2026 18:59:38 +0530 Subject: [PATCH 1/4] feat: implement content length validation for enabling review notes --- .../Experiments/Review_Notes/Review_Notes.php | 13 +++- .../components/ReviewNotesPlugin.tsx | 40 +++++++++--- .../review-notes/hooks/useReviewNotes.ts | 61 ++++++++++++++++--- 3 files changed, 95 insertions(+), 19 deletions(-) diff --git a/includes/Experiments/Review_Notes/Review_Notes.php b/includes/Experiments/Review_Notes/Review_Notes.php index daa2b9033..7597864e9 100644 --- a/includes/Experiments/Review_Notes/Review_Notes.php +++ b/includes/Experiments/Review_Notes/Review_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( 'review_notes', 'experiments/review-notes' ); + + /** + * Filters the minimum content length required to enable review 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_review_notes_min_content_length', 100 ); + Asset_Loader::localize_script( 'review_notes', 'ReviewNotesData', array( - 'enabled' => $this->is_enabled(), + 'enabled' => $this->is_enabled(), + 'minContentLength' => $min_content_length, ) ); } diff --git a/src/experiments/review-notes/components/ReviewNotesPlugin.tsx b/src/experiments/review-notes/components/ReviewNotesPlugin.tsx index 2d45dc335..3e912f895 100644 --- a/src/experiments/review-notes/components/ReviewNotesPlugin.tsx +++ b/src/experiments/review-notes/components/ReviewNotesPlugin.tsx @@ -34,9 +34,20 @@ import { useReviewBlock, useReviewNotes } from '../hooks/useReviewNotes'; * reviewable blocks. */ export default function ReviewNotesPlugin() { - const { isReviewing, progress, total, lastRunCount, runReview } = - useReviewNotes(); - const { isReviewing: isReviewingBlock, reviewBlock } = useReviewBlock(); + const { + isReviewing, + progress, + total, + lastRunCount, + runReview, + isContentTooShort, + minContentLength, + } = useReviewNotes(); + const { + isReviewing: isReviewingBlock, + reviewBlock, + isContentTooShort: isBlockReviewDisabled, + } = useReviewBlock(); const { openGeneralSidebar } = useDispatch( editPostStore ); const openNotesPanel = () => openGeneralSidebar?.( 'edit-post/collab-sidebar' ); @@ -54,10 +65,19 @@ export default function ReviewNotesPlugin() { const buttonLabel = isReviewing ? reviewingLabel : __( 'Generate Review Notes', 'ai' ); - const buttonDescription = __( - 'This will review the content of this post block-by-block, and create Notes attached to each block with suggestions.', - 'ai' - ); + const buttonDescription = isContentTooShort + ? sprintf( + /* translators: %d: minimum number of characters required. */ + __( + 'Review Notes will be available when the post content has at least %d characters.', + 'ai' + ), + minContentLength + ) + : __( + 'This will review the content of this post block-by-block, and create Notes attached to each block with suggestions.', + 'ai' + ); return ( <> @@ -69,7 +89,7 @@ export default function ReviewNotesPlugin() { icon={ commentContent } onClick={ runReview } isBusy={ isReviewing } - disabled={ isReviewing } + disabled={ isReviewing || isContentTooShort } style={ { justifyContent: 'center', width: '100%', @@ -136,7 +156,9 @@ export default function ReviewNotesPlugin() { icon={ isReviewingBlock ? : commentContent } - disabled={ isReviewingBlock } + disabled={ + isReviewingBlock || isBlockReviewDisabled + } onClick={ () => { if ( clientId ) { reviewBlock( clientId ); diff --git a/src/experiments/review-notes/hooks/useReviewNotes.ts b/src/experiments/review-notes/hooks/useReviewNotes.ts index 0c0028570..ae4d8db8d 100644 --- a/src/experiments/review-notes/hooks/useReviewNotes.ts +++ b/src/experiments/review-notes/hooks/useReviewNotes.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 @@ -68,6 +69,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. * @@ -163,14 +190,22 @@ export function useReviewNotes(): { 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 ( isContentTooShort ) { + return; + } + setIsReviewing( true ); setProgress( 0 ); setTotal( 0 ); @@ -184,9 +219,6 @@ export function useReviewNotes(): { 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 = ( @@ -275,7 +307,15 @@ export function useReviewNotes(): { } }; - return { isReviewing, progress, total, lastRunCount, runReview }; + return { + isReviewing, + progress, + total, + lastRunCount, + isContentTooShort, + minContentLength, + runReview, + }; } /** @@ -285,11 +325,17 @@ export function useReviewNotes(): { */ export function useReviewBlock(): { 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( @@ -308,9 +354,6 @@ export function useReviewBlock(): { 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( [ @@ -375,7 +418,7 @@ export function useReviewBlock(): { } }; - return { isReviewing, reviewBlock }; + return { isReviewing, isContentTooShort, reviewBlock }; } /** From 950d0655c508bebfc38c77e147164bf5abd8013d Mon Sep 17 00:00:00 2001 From: Gautam Mehta Date: Wed, 13 May 2026 19:00:26 +0530 Subject: [PATCH 2/4] tests: add tests for Review Notes feature content length validation --- .../Review_Notes/Review_NotesTest.php | 40 ++++++++++ .../specs/experiments/review-notes.spec.js | 75 +++++++++++++++++++ 2 files changed, 115 insertions(+) diff --git a/tests/Integration/Includes/Experiments/Review_Notes/Review_NotesTest.php b/tests/Integration/Includes/Experiments/Review_Notes/Review_NotesTest.php index 1369f235a..caa208701 100644 --- a/tests/Integration/Includes/Experiments/Review_Notes/Review_NotesTest.php +++ b/tests/Integration/Includes/Experiments/Review_Notes/Review_NotesTest.php @@ -112,6 +112,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_review_notes', 'enqueued' ) ); + $this->assertStringContainsString( + '"minContentLength":100', + (string) wp_scripts()->get_data( 'ai_review_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_review_notes_min_content_length', $filter ); + $GLOBALS['wp_scripts'] = new \WP_Scripts(); + + $this->experiment->enqueue_assets(); + + remove_filter( 'wpai_review_notes_min_content_length', $filter ); + + $this->assertStringContainsString( + '"minContentLength":250', + (string) wp_scripts()->get_data( 'ai_review_notes', 'data' ) + ); + } + // ------------------------------------------------------------------------- // maybe_set_ai_author() // ------------------------------------------------------------------------- diff --git a/tests/e2e/specs/experiments/review-notes.spec.js b/tests/e2e/specs/experiments/review-notes.spec.js index eee97b906..e8e78078d 100644 --- a/tests/e2e/specs/experiments/review-notes.spec.js +++ b/tests/e2e/specs/experiments/review-notes.spec.js @@ -40,6 +40,58 @@ test.describe( 'AI Review Notes Experiment', () => { ).toBeVisible(); } ); + test( 'Disables Review Notes until the post content reaches the minimum length', async ( { + admin, + editor, + page, + } ) => { + await admin.createNewPost( { + title: 'Short Review Notes Test', + content: 'Too short.', + } ); + + await editor.openDocumentSettingsSidebar(); + + const reviewButton = page.getByRole( 'button', { + name: 'Generate Review Notes', + } ); + await expect( reviewButton ).toBeVisible(); + await expect( reviewButton ).toBeDisabled(); + + await expect( + page.locator( '.description', { + hasText: + 'Review Notes will be available when the post content has at least 100 characters.', + } ) + ).toBeVisible(); + } ); + + test( 'Enables Review Notes once the post content meets the minimum length', async ( { + admin, + editor, + page, + } ) => { + await admin.createNewPost( { + title: 'Long Review Notes Test', + content: + 'This paragraph contains enough content for the Review Notes feature to become available and analyze the post block-by-block.', + } ); + + await editor.openDocumentSettingsSidebar(); + + const reviewButton = page.getByRole( 'button', { + name: 'Generate Review Notes', + } ); + await expect( reviewButton ).toBeVisible(); + await expect( reviewButton ).toBeEnabled(); + + await expect( + page.locator( '.description', { + hasText: 'at least 100 characters', + } ) + ).toHaveCount( 0 ); + } ); + test( 'Shows the "Review with AI" button in the block toolbar', async ( { admin, editor, @@ -67,6 +119,29 @@ test.describe( 'AI Review Notes Experiment', () => { ).toBeVisible(); } ); + test( 'Disables single-block Review 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 Review Note' } ) + ).toBeDisabled(); + } ); + test( 'Shows suggestion count after a successful review', async ( { admin, editor, From a52834c6c40a539aa7a6a40831f5d78ebda5da88 Mon Sep 17 00:00:00 2001 From: Gautam Mehta Date: Thu, 14 May 2026 13:23:42 +0530 Subject: [PATCH 3/4] tests: fix assertion --- .../Includes/Experiments/Review_Notes/Review_NotesTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Integration/Includes/Experiments/Review_Notes/Review_NotesTest.php b/tests/Integration/Includes/Experiments/Review_Notes/Review_NotesTest.php index caa208701..7c96bbcf0 100644 --- a/tests/Integration/Includes/Experiments/Review_Notes/Review_NotesTest.php +++ b/tests/Integration/Includes/Experiments/Review_Notes/Review_NotesTest.php @@ -124,7 +124,7 @@ public function test_enqueue_assets_localizes_default_min_content_length() { $this->assertTrue( wp_script_is( 'ai_review_notes', 'enqueued' ) ); $this->assertStringContainsString( - '"minContentLength":100', + '"minContentLength":"100"', (string) wp_scripts()->get_data( 'ai_review_notes', 'data' ) ); } @@ -147,7 +147,7 @@ public function test_enqueue_assets_localizes_filtered_min_content_length() { remove_filter( 'wpai_review_notes_min_content_length', $filter ); $this->assertStringContainsString( - '"minContentLength":250', + '"minContentLength":"250"', (string) wp_scripts()->get_data( 'ai_review_notes', 'data' ) ); } From 35adacb8ed897fbee808c5db4e82160236f0d2a3 Mon Sep 17 00:00:00 2001 From: Gautam Mehta Date: Thu, 14 May 2026 14:31:48 +0530 Subject: [PATCH 4/4] tests: fix old test failing due to new behaviour --- .../e2e/specs/experiments/review-notes.spec.js | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/tests/e2e/specs/experiments/review-notes.spec.js b/tests/e2e/specs/experiments/review-notes.spec.js index e8e78078d..ad3d682e7 100644 --- a/tests/e2e/specs/experiments/review-notes.spec.js +++ b/tests/e2e/specs/experiments/review-notes.spec.js @@ -208,12 +208,12 @@ test.describe( 'AI Review Notes Experiment', () => { ).toBeVisible(); } ); - test( 'Does nothing when post has no reviewable blocks', async ( { + test( 'Disables Review 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. @@ -223,15 +223,14 @@ test.describe( 'AI Review Notes Experiment', () => { name: 'Generate Review 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: + 'Review Notes will be available when the post content has at least 100 characters.', + } ) ).toBeVisible(); } );