From 63f70c6ab4a2e815ca58b93112c713034132bcb7 Mon Sep 17 00:00:00 2001 From: vraja-pro Date: Tue, 4 Aug 2026 14:47:03 +0300 Subject: [PATCH 01/34] fix(bulk-editor): resolve post type default template when SEO title/description is empty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a post has never had its SEO title or meta description explicitly saved, the bulk editor's GET /bulk_editor/posts path was returning empty strings for both fields. The single-post editor resolves the post type's configured template via wpseo_replace_vars() and shows that value; the bulk editor did not. Adds Default_Template_Resolver, a shared infrastructure helper that falls back to the post type's configured template (SEO > Settings) — and for the SEO title also to the installation default — whenever the raw stored value is empty, then passes it through wpseo_replace_vars() with the post as context. Both Indexable_Posts_Collector and Post_Meta_Posts_Collector now inject and call this resolver on the list/load path, so the resolved value reaches the API response and the needs_improvement verdict for each field is computed from the resolved value rather than the empty stored value. Co-Authored-By: Claude Sonnet 4.6 --- .../posts/default-template-resolver.php | 79 +++++++++++++++++ .../posts/indexable-posts-collector.php | 41 +++++++-- .../posts/post-meta-posts-collector.php | 25 +++++- ...bstract_Default_Template_Resolver_Test.php | 44 ++++++++++ .../Resolve_Meta_Description_Test.php | 66 ++++++++++++++ .../Resolve_Seo_Title_Test.php | 86 +++++++++++++++++++ ...bstract_Indexable_Posts_Collector_Test.php | 19 +++- .../Get_Posts_Test.php | 44 ++++++++++ ...bstract_Post_Meta_Posts_Collector_Test.php | 18 +++- .../Build_Needs_Improvement_Where_Test.php | 6 +- .../Build_Query_Args_Test.php | 4 +- .../Get_Posts_Test.php | 74 +++++++++++++++- .../Post_Meta_Posts_Collector_Double.php | 18 ++++ 13 files changed, 505 insertions(+), 19 deletions(-) create mode 100644 src/bulk-editor/infrastructure/posts/default-template-resolver.php create mode 100644 tests/Unit/Bulk_Editor/Infrastructure/Posts/Default_Template_Resolver/Abstract_Default_Template_Resolver_Test.php create mode 100644 tests/Unit/Bulk_Editor/Infrastructure/Posts/Default_Template_Resolver/Resolve_Meta_Description_Test.php create mode 100644 tests/Unit/Bulk_Editor/Infrastructure/Posts/Default_Template_Resolver/Resolve_Seo_Title_Test.php diff --git a/src/bulk-editor/infrastructure/posts/default-template-resolver.php b/src/bulk-editor/infrastructure/posts/default-template-resolver.php new file mode 100644 index 00000000000..e51a6f2fbc3 --- /dev/null +++ b/src/bulk-editor/infrastructure/posts/default-template-resolver.php @@ -0,0 +1,79 @@ +options_helper = $options_helper; + } + + /** + * Returns the SEO title for a post, falling back to the post type's configured template when empty. + * + * Priority mirrors the presentation layer: stored value → user-configured post type template + * (SEO > Settings, `title-{post_type}`) → installation default. + * + * @param int $post_id The post ID. + * @param string $post_type The post type slug. + * @param string $stored_value The raw stored title (empty string when never explicitly saved). + * + * @return string The resolved SEO title. + */ + public function resolve_seo_title( int $post_id, string $post_type, string $stored_value ): string { + if ( $stored_value !== '' ) { + return $stored_value; + } + + $template = (string) $this->options_helper->get( 'title-' . $post_type, '' ); + if ( $template === '' ) { + $template = (string) $this->options_helper->get_title_default( 'title-' . $post_type ); + } + if ( $template === '' ) { + return ''; + } + + return (string) \wpseo_replace_vars( $template, \get_post( $post_id ) ); + } + + /** + * Returns the meta description for a post, falling back to the post type's configured template when empty. + * + * @param int $post_id The post ID. + * @param string $post_type The post type slug. + * @param string $stored_value The raw stored description (empty string when never explicitly saved). + * + * @return string The resolved meta description. + */ + public function resolve_meta_description( int $post_id, string $post_type, string $stored_value ): string { + if ( $stored_value !== '' ) { + return $stored_value; + } + + $template = (string) $this->options_helper->get( 'metadesc-' . $post_type, '' ); + if ( $template === '' ) { + return ''; + } + + return (string) \wpseo_replace_vars( $template, \get_post( $post_id ) ); + } +} diff --git a/src/bulk-editor/infrastructure/posts/indexable-posts-collector.php b/src/bulk-editor/infrastructure/posts/indexable-posts-collector.php index 79497d69785..65b2328f886 100644 --- a/src/bulk-editor/infrastructure/posts/indexable-posts-collector.php +++ b/src/bulk-editor/infrastructure/posts/indexable-posts-collector.php @@ -60,18 +60,28 @@ class Indexable_Posts_Collector implements Posts_Collector_Interface { */ private $post_editability_resolver; + /** + * The resolver for the post type's default SEO title / meta description template. + * + * @var Default_Template_Resolver + */ + private $default_template_resolver; + /** * The constructor. * * @param Indexable_Repository $indexable_repository The indexable repository. * @param Post_Editability_Resolver $post_editability_resolver The resolver for the per-post edit permission. + * @param Default_Template_Resolver $default_template_resolver The resolver for the default SEO title / meta description template. */ public function __construct( Indexable_Repository $indexable_repository, - Post_Editability_Resolver $post_editability_resolver + Post_Editability_Resolver $post_editability_resolver, + Default_Template_Resolver $default_template_resolver ) { $this->indexable_repository = $indexable_repository; $this->post_editability_resolver = $post_editability_resolver; + $this->default_template_resolver = $default_template_resolver; } /** @@ -264,18 +274,22 @@ private function build_post( Indexable $indexable, bool $editable, bool $scores_ return new Post( $object_id, $title, (string) $indexable->post_status, '', '', '', '', '', '', false ); } + $post_type = (string) $indexable->object_sub_type; + $seo_title = $this->default_template_resolver->resolve_seo_title( $object_id, $post_type, (string) $indexable->title ); + $meta_description = $this->default_template_resolver->resolve_meta_description( $object_id, $post_type, (string) $indexable->description ); + return new Post( $object_id, $title, (string) $indexable->post_status, (string) \get_edit_post_link( $object_id, 'raw' ), (string) $indexable->primary_focus_keyword, - (string) $indexable->title, - (string) $indexable->description, + $seo_title, + $meta_description, (string) $indexable->open_graph_title, (string) $indexable->open_graph_description, true, - $this->build_needs_improvement( $indexable, $scores_enabled ), + $this->build_needs_improvement( $indexable, $scores_enabled, $seo_title, $meta_description ), ); } @@ -283,16 +297,27 @@ private function build_post( Indexable $indexable, bool $editable, bool $scores_ * Builds the per-field needs-improvement verdict for a post, keyed by field param. * * A field needs improvement when its value is empty, or when its score falls in the bad/ok range. + * The SEO title and meta description use their already-resolved values (which may have been filled + * in from the post type's default template) so a post with a non-empty template is not flagged + * as needing improvement merely because its stored value was never explicitly saved. * - * @param Indexable $indexable The indexable. - * @param bool $scores_enabled Whether the per-field scores may back the verdict. + * @param Indexable $indexable The indexable. + * @param bool $scores_enabled Whether the per-field scores may back the verdict. + * @param string $seo_title The resolved SEO title (may differ from the raw indexable value). + * @param string $meta_description The resolved meta description (may differ from the raw indexable value). * * @return array Whether each field needs improvement, keyed by field param. */ - private function build_needs_improvement( Indexable $indexable, bool $scores_enabled ): array { + private function build_needs_improvement( Indexable $indexable, bool $scores_enabled, string $seo_title, string $meta_description ): array { + $resolved_values = [ + 'seo_title' => $seo_title, + 'meta_description' => $meta_description, + ]; + $needs_improvement = []; foreach ( self::FIELD_COLUMNS as $field => $column ) { - $is_empty = ( (string) $indexable->{$column} === '' ); + $value = ( $resolved_values[ $field ] ?? (string) $indexable->{$column} ); + $is_empty = ( $value === '' ); $is_bad_score = false; if ( $scores_enabled && isset( self::FIELD_SCORE_COLUMNS[ $field ] ) ) { diff --git a/src/bulk-editor/infrastructure/posts/post-meta-posts-collector.php b/src/bulk-editor/infrastructure/posts/post-meta-posts-collector.php index 12fe1d00459..dec287794ae 100644 --- a/src/bulk-editor/infrastructure/posts/post-meta-posts-collector.php +++ b/src/bulk-editor/infrastructure/posts/post-meta-posts-collector.php @@ -75,13 +75,25 @@ class Post_Meta_Posts_Collector implements Posts_Collector_Interface { */ private $post_editability_resolver; + /** + * The resolver for the post type's default SEO title / meta description template. + * + * @var Default_Template_Resolver + */ + private $default_template_resolver; + /** * The constructor. * * @param Post_Editability_Resolver $post_editability_resolver The resolver for the per-post edit permission. + * @param Default_Template_Resolver $default_template_resolver The resolver for the default SEO title / meta description template. */ - public function __construct( Post_Editability_Resolver $post_editability_resolver ) { + public function __construct( + Post_Editability_Resolver $post_editability_resolver, + Default_Template_Resolver $default_template_resolver + ) { $this->post_editability_resolver = $post_editability_resolver; + $this->default_template_resolver = $default_template_resolver; } /** @@ -209,9 +221,10 @@ public function filter_posts_where( $where, $wp_query ): string { * @return Post The post. */ private function build_post( int $post_id, bool $editable, bool $scores_enabled ): Post { - $post = \get_post( $post_id ); - $status = ( $post !== null ) ? (string) $post->post_status : ''; - $title = $this->get_normalized_title( $post_id ); + $post = \get_post( $post_id ); + $status = ( $post !== null ) ? (string) $post->post_status : ''; + $post_type = ( $post !== null ) ? (string) $post->post_type : ''; + $title = $this->get_normalized_title( $post_id ); if ( ! $editable ) { return new Post( $post_id, $title, $status, '', '', '', '', '', '', false ); @@ -224,6 +237,10 @@ private function build_post( int $post_id, bool $editable, bool $scores_enabled $fields[ $field ] = $this->get_meta( $post_id, $suffix ); } + // Fall back to the post type's default template when the stored value is empty. + $fields['seo_title'] = $this->default_template_resolver->resolve_seo_title( $post_id, $post_type, $fields['seo_title'] ); + $fields['meta_description'] = $this->default_template_resolver->resolve_meta_description( $post_id, $post_type, $fields['meta_description'] ); + return new Post( $post_id, $title, diff --git a/tests/Unit/Bulk_Editor/Infrastructure/Posts/Default_Template_Resolver/Abstract_Default_Template_Resolver_Test.php b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Default_Template_Resolver/Abstract_Default_Template_Resolver_Test.php new file mode 100644 index 00000000000..18e4d461490 --- /dev/null +++ b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Default_Template_Resolver/Abstract_Default_Template_Resolver_Test.php @@ -0,0 +1,44 @@ +options_helper = Mockery::mock( Options_Helper::class ); + $this->instance = new Default_Template_Resolver( $this->options_helper ); + } +} diff --git a/tests/Unit/Bulk_Editor/Infrastructure/Posts/Default_Template_Resolver/Resolve_Meta_Description_Test.php b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Default_Template_Resolver/Resolve_Meta_Description_Test.php new file mode 100644 index 00000000000..6d8663a093f --- /dev/null +++ b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Default_Template_Resolver/Resolve_Meta_Description_Test.php @@ -0,0 +1,66 @@ +options_helper->expects( 'get' )->never(); + + $result = $this->instance->resolve_meta_description( 7, 'post', 'My explicit description.' ); + + $this->assertSame( 'My explicit description.', $result ); + } + + /** + * Tests that the user-configured post type template is resolved when the stored value is empty. + * + * @return void + */ + public function test_resolves_from_configured_template_when_stored_value_is_empty() { + $post = (object) [ 'ID' => 7 ]; + + $this->options_helper->expects( 'get' )->with( 'metadesc-post', '' )->andReturn( '%%excerpt%%' ); + + Functions\expect( 'get_post' )->once()->with( 7 )->andReturn( $post ); + Functions\expect( 'wpseo_replace_vars' )->once()->with( '%%excerpt%%', $post )->andReturn( 'The post excerpt.' ); + + $result = $this->instance->resolve_meta_description( 7, 'post', '' ); + + $this->assertSame( 'The post excerpt.', $result ); + } + + /** + * Tests that an empty string is returned when no template is configured for the post type. + * + * Unlike SEO title, meta description has no installation-level default fallback. + * + * @return void + */ + public function test_returns_empty_when_no_template_is_configured() { + $this->options_helper->expects( 'get' )->with( 'metadesc-page', '' )->andReturn( '' ); + + Functions\expect( 'get_post' )->never(); + Functions\expect( 'wpseo_replace_vars' )->never(); + + $result = $this->instance->resolve_meta_description( 7, 'page', '' ); + + $this->assertSame( '', $result ); + } +} diff --git a/tests/Unit/Bulk_Editor/Infrastructure/Posts/Default_Template_Resolver/Resolve_Seo_Title_Test.php b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Default_Template_Resolver/Resolve_Seo_Title_Test.php new file mode 100644 index 00000000000..0c05886944c --- /dev/null +++ b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Default_Template_Resolver/Resolve_Seo_Title_Test.php @@ -0,0 +1,86 @@ +options_helper->expects( 'get' )->never(); + $this->options_helper->expects( 'get_title_default' )->never(); + + $result = $this->instance->resolve_seo_title( 7, 'post', 'My explicit title' ); + + $this->assertSame( 'My explicit title', $result ); + } + + /** + * Tests that the user-configured post type template is resolved when the stored value is empty. + * + * @return void + */ + public function test_resolves_from_configured_template_when_stored_value_is_empty() { + $post = (object) [ 'ID' => 7 ]; + + $this->options_helper->expects( 'get' )->with( 'title-post', '' )->andReturn( '%%title%% - %%sitename%%' ); + $this->options_helper->expects( 'get_title_default' )->never(); + + Functions\expect( 'get_post' )->once()->with( 7 )->andReturn( $post ); + Functions\expect( 'wpseo_replace_vars' )->once()->with( '%%title%% - %%sitename%%', $post )->andReturn( 'My post - My Site' ); + + $result = $this->instance->resolve_seo_title( 7, 'post', '' ); + + $this->assertSame( 'My post - My Site', $result ); + } + + /** + * Tests that the installation default is tried when the user has not configured a template. + * + * @return void + */ + public function test_resolves_from_default_template_when_configured_template_is_empty() { + $post = (object) [ 'ID' => 7 ]; + + $this->options_helper->expects( 'get' )->with( 'title-page', '' )->andReturn( '' ); + $this->options_helper->expects( 'get_title_default' )->with( 'title-page' )->andReturn( '%%title%% %%page%% %%sep%% %%sitename%%' ); + + Functions\expect( 'get_post' )->once()->with( 7 )->andReturn( $post ); + Functions\expect( 'wpseo_replace_vars' )->once()->with( '%%title%% %%page%% %%sep%% %%sitename%%', $post )->andReturn( 'A page - My Site' ); + + $result = $this->instance->resolve_seo_title( 7, 'page', '' ); + + $this->assertSame( 'A page - My Site', $result ); + } + + /** + * Tests that an empty string is returned when neither a configured template nor an installation default exists. + * + * @return void + */ + public function test_returns_empty_when_no_template_exists() { + $this->options_helper->expects( 'get' )->with( 'title-post', '' )->andReturn( '' ); + $this->options_helper->expects( 'get_title_default' )->with( 'title-post' )->andReturn( '' ); + + Functions\expect( 'get_post' )->never(); + Functions\expect( 'wpseo_replace_vars' )->never(); + + $result = $this->instance->resolve_seo_title( 7, 'post', '' ); + + $this->assertSame( '', $result ); + } +} diff --git a/tests/Unit/Bulk_Editor/Infrastructure/Posts/Indexable_Posts_Collector/Abstract_Indexable_Posts_Collector_Test.php b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Indexable_Posts_Collector/Abstract_Indexable_Posts_Collector_Test.php index 976ad0fb9b8..9046acd020e 100644 --- a/tests/Unit/Bulk_Editor/Infrastructure/Posts/Indexable_Posts_Collector/Abstract_Indexable_Posts_Collector_Test.php +++ b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Indexable_Posts_Collector/Abstract_Indexable_Posts_Collector_Test.php @@ -5,6 +5,7 @@ namespace Yoast\WP\SEO\Tests\Unit\Bulk_Editor\Infrastructure\Posts\Indexable_Posts_Collector; use Mockery; +use Yoast\WP\SEO\Bulk_Editor\Infrastructure\Posts\Default_Template_Resolver; use Yoast\WP\SEO\Bulk_Editor\Infrastructure\Posts\Indexable_Posts_Collector; use Yoast\WP\SEO\Bulk_Editor\Infrastructure\Posts\Post_Editability_Resolver; use Yoast\WP\SEO\Repositories\Indexable_Repository; @@ -38,6 +39,13 @@ abstract class Abstract_Indexable_Posts_Collector_Test extends TestCase { */ protected $post_editability_resolver; + /** + * Holds the default template resolver. + * + * @var Mockery\MockInterface|Default_Template_Resolver + */ + protected $default_template_resolver; + /** * Sets up the test fixtures. * @@ -48,7 +56,16 @@ protected function set_up() { $this->indexable_repository = Mockery::mock( Indexable_Repository::class ); $this->post_editability_resolver = Mockery::mock( Post_Editability_Resolver::class ); + $this->default_template_resolver = Mockery::mock( Default_Template_Resolver::class ); + + // Pass the stored value through unchanged by default; individual tests override when needed. + $this->default_template_resolver->allows( 'resolve_seo_title' )->andReturnArg( 2 )->byDefault(); + $this->default_template_resolver->allows( 'resolve_meta_description' )->andReturnArg( 2 )->byDefault(); - $this->instance = new Indexable_Posts_Collector( $this->indexable_repository, $this->post_editability_resolver ); + $this->instance = new Indexable_Posts_Collector( + $this->indexable_repository, + $this->post_editability_resolver, + $this->default_template_resolver, + ); } } diff --git a/tests/Unit/Bulk_Editor/Infrastructure/Posts/Indexable_Posts_Collector/Get_Posts_Test.php b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Indexable_Posts_Collector/Get_Posts_Test.php index fab5bbf8758..56df15be13d 100644 --- a/tests/Unit/Bulk_Editor/Infrastructure/Posts/Indexable_Posts_Collector/Get_Posts_Test.php +++ b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Indexable_Posts_Collector/Get_Posts_Test.php @@ -21,6 +21,7 @@ * @covers Yoast\WP\SEO\Bulk_Editor\Infrastructure\Posts\Indexable_Posts_Collector::apply_search * @covers Yoast\WP\SEO\Bulk_Editor\Infrastructure\Posts\Indexable_Posts_Collector::apply_needs_improvement * @covers Yoast\WP\SEO\Bulk_Editor\Infrastructure\Posts\Indexable_Posts_Collector::build_post + * @covers Yoast\WP\SEO\Bulk_Editor\Infrastructure\Posts\Indexable_Posts_Collector::build_needs_improvement */ final class Get_Posts_Test extends Abstract_Indexable_Posts_Collector_Test { @@ -89,6 +90,49 @@ public function test_get_posts_editable() { ); } + /** + * Tests that the SEO title and meta description fall back to the resolved template when the stored + * values are empty, and that the post is not flagged as needing improvement. + * + * @return void + */ + public function test_get_posts_resolves_template_when_stored_values_are_empty() { + $indexable = new Indexable_Mock(); + $indexable->object_id = 7; + $indexable->object_sub_type = 'page'; + $indexable->post_status = 'draft'; + $indexable->primary_focus_keyword = ''; + $indexable->title = ''; + $indexable->description = ''; + $indexable->open_graph_title = ''; + $indexable->open_graph_description = ''; + $indexable->seo_title_score = 0; + $indexable->meta_description_score = 0; + + $query = $this->stub_page_query( [ $indexable ] ); + $query->expects( 'count' )->never(); + + $this->post_editability_resolver->expects( 'resolve' )->with( [ 7 ] )->andReturn( [ 7 => true ] ); + + $this->default_template_resolver->expects( 'resolve_seo_title' ) + ->with( 7, 'page', '' ) + ->andReturn( 'Page title from template' ); + $this->default_template_resolver->expects( 'resolve_meta_description' ) + ->with( 7, 'page', '' ) + ->andReturn( 'Page description from template' ); + + Functions\expect( 'get_the_title' )->once()->with( 7 )->andReturn( 'A page' ); + Functions\expect( 'get_edit_post_link' )->once()->with( 7, 'raw' )->andReturn( 'post.php?post=7&action=edit' ); + + $result = $this->instance->get_posts( new Posts_Query( 'page', 1, 20, '', self::STATUSES ) )->to_array(); + $post = $result['posts'][0]; + + $this->assertSame( 'Page title from template', $post['seo_title'] ); + $this->assertSame( 'Page description from template', $post['meta_description'] ); + $this->assertFalse( $post['needs_improvement']['seo_title'] ); + $this->assertFalse( $post['needs_improvement']['meta_description'] ); + } + /** * Tests that a non-editable post is returned locked and without its SEO data. * diff --git a/tests/Unit/Bulk_Editor/Infrastructure/Posts/Post_Meta_Posts_Collector/Abstract_Post_Meta_Posts_Collector_Test.php b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Post_Meta_Posts_Collector/Abstract_Post_Meta_Posts_Collector_Test.php index 66297e17b4c..793aea60984 100644 --- a/tests/Unit/Bulk_Editor/Infrastructure/Posts/Post_Meta_Posts_Collector/Abstract_Post_Meta_Posts_Collector_Test.php +++ b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Post_Meta_Posts_Collector/Abstract_Post_Meta_Posts_Collector_Test.php @@ -5,6 +5,7 @@ namespace Yoast\WP\SEO\Tests\Unit\Bulk_Editor\Infrastructure\Posts\Post_Meta_Posts_Collector; use Mockery; +use Yoast\WP\SEO\Bulk_Editor\Infrastructure\Posts\Default_Template_Resolver; use Yoast\WP\SEO\Bulk_Editor\Infrastructure\Posts\Post_Editability_Resolver; use Yoast\WP\SEO\Bulk_Editor\Infrastructure\Posts\Post_Meta_Posts_Collector; use Yoast\WP\SEO\Tests\Unit\TestCase; @@ -23,6 +24,13 @@ abstract class Abstract_Post_Meta_Posts_Collector_Test extends TestCase { */ protected $post_editability_resolver; + /** + * Holds the default template resolver. + * + * @var Mockery\MockInterface|Default_Template_Resolver + */ + protected $default_template_resolver; + /** * Holds the instance. * @@ -41,8 +49,16 @@ protected function set_up() { parent::set_up(); $this->post_editability_resolver = Mockery::mock( Post_Editability_Resolver::class ); + $this->default_template_resolver = Mockery::mock( Default_Template_Resolver::class ); + + // Pass the stored value through unchanged by default; individual tests override when needed. + $this->default_template_resolver->allows( 'resolve_seo_title' )->andReturnArg( 2 )->byDefault(); + $this->default_template_resolver->allows( 'resolve_meta_description' )->andReturnArg( 2 )->byDefault(); - $this->instance = Mockery::mock( Post_Meta_Posts_Collector::class, [ $this->post_editability_resolver ] ) + $this->instance = Mockery::mock( + Post_Meta_Posts_Collector::class, + [ $this->post_editability_resolver, $this->default_template_resolver ], + ) ->makePartial() ->shouldAllowMockingProtectedMethods(); } diff --git a/tests/Unit/Bulk_Editor/Infrastructure/Posts/Post_Meta_Posts_Collector/Build_Needs_Improvement_Where_Test.php b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Post_Meta_Posts_Collector/Build_Needs_Improvement_Where_Test.php index e6861a0fca5..2d6fc25bd5e 100644 --- a/tests/Unit/Bulk_Editor/Infrastructure/Posts/Post_Meta_Posts_Collector/Build_Needs_Improvement_Where_Test.php +++ b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Post_Meta_Posts_Collector/Build_Needs_Improvement_Where_Test.php @@ -5,6 +5,7 @@ namespace Yoast\WP\SEO\Tests\Unit\Bulk_Editor\Infrastructure\Posts\Post_Meta_Posts_Collector; use Mockery; +use Yoast\WP\SEO\Bulk_Editor\Infrastructure\Posts\Default_Template_Resolver; use Yoast\WP\SEO\Bulk_Editor\Infrastructure\Posts\Post_Editability_Resolver; use Yoast\WP\SEO\Tests\Unit\Doubles\Bulk_Editor\Post_Meta_Posts_Collector_Double; use Yoast\WP\SEO\Tests\Unit\TestCase; @@ -42,7 +43,10 @@ protected function set_up() { $wpdb->postmeta = 'wp_postmeta'; $wpdb->allows( 'prepare' )->andReturnUsing( [ $this, 'interpolate_query' ] ); - $this->instance = new Post_Meta_Posts_Collector_Double( Mockery::mock( Post_Editability_Resolver::class ) ); + $this->instance = new Post_Meta_Posts_Collector_Double( + Mockery::mock( Post_Editability_Resolver::class ), + Mockery::mock( Default_Template_Resolver::class ), + ); } /** diff --git a/tests/Unit/Bulk_Editor/Infrastructure/Posts/Post_Meta_Posts_Collector/Build_Query_Args_Test.php b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Post_Meta_Posts_Collector/Build_Query_Args_Test.php index 96aed9e9353..8f3c9b8fb6a 100644 --- a/tests/Unit/Bulk_Editor/Infrastructure/Posts/Post_Meta_Posts_Collector/Build_Query_Args_Test.php +++ b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Post_Meta_Posts_Collector/Build_Query_Args_Test.php @@ -4,8 +4,10 @@ // phpcs:disable Yoast.NamingConventions.NamespaceName.MaxExceeded namespace Yoast\WP\SEO\Tests\Unit\Bulk_Editor\Infrastructure\Posts\Post_Meta_Posts_Collector; +use Mockery; use ReflectionMethod; use Yoast\WP\SEO\Bulk_Editor\Domain\Posts\Posts_Query; +use Yoast\WP\SEO\Bulk_Editor\Infrastructure\Posts\Default_Template_Resolver; use Yoast\WP\SEO\Bulk_Editor\Infrastructure\Posts\Post_Meta_Posts_Collector; /** @@ -58,7 +60,7 @@ public function test_build_query_args_without_included_post_ids() { * @return array|array> The built WP_Query arguments. */ private function invoke_build_query_args( Posts_Query $query ): array { - $instance = new Post_Meta_Posts_Collector( $this->post_editability_resolver ); + $instance = new Post_Meta_Posts_Collector( $this->post_editability_resolver, Mockery::mock( Default_Template_Resolver::class ) ); $reflection = new ReflectionMethod( $instance, 'build_query_args' ); $reflection->setAccessible( true ); diff --git a/tests/Unit/Bulk_Editor/Infrastructure/Posts/Post_Meta_Posts_Collector/Get_Posts_Test.php b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Post_Meta_Posts_Collector/Get_Posts_Test.php index 486fd835ee8..6178cce860d 100644 --- a/tests/Unit/Bulk_Editor/Infrastructure/Posts/Post_Meta_Posts_Collector/Get_Posts_Test.php +++ b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Post_Meta_Posts_Collector/Get_Posts_Test.php @@ -45,7 +45,12 @@ public function test_get_posts_editable() { $this->post_editability_resolver->expects( 'resolve' )->with( [ 7 ] )->andReturn( [ 7 => true ] ); - Functions\expect( 'get_post' )->once()->with( 7 )->andReturn( (object) [ 'post_status' => 'draft' ] ); + Functions\expect( 'get_post' )->once()->with( 7 )->andReturn( + (object) [ + 'post_status' => 'draft', + 'post_type' => 'post', + ], + ); Functions\expect( 'get_the_title' )->once()->with( 7 )->andReturn( 'Hello world' ); Functions\expect( 'get_edit_post_link' )->once()->with( 7, 'raw' )->andReturn( 'post.php?post=7&action=edit' ); Functions\expect( 'get_post_meta' ) @@ -97,7 +102,12 @@ public function test_get_posts_locks_non_editable_post() { $this->post_editability_resolver->expects( 'resolve' )->with( [ 7 ] )->andReturn( [ 7 => false ] ); - Functions\expect( 'get_post' )->once()->with( 7 )->andReturn( (object) [ 'post_status' => 'publish' ] ); + Functions\expect( 'get_post' )->once()->with( 7 )->andReturn( + (object) [ + 'post_status' => 'publish', + 'post_type' => 'post', + ], + ); Functions\expect( 'get_the_title' )->once()->with( 7 )->andReturn( 'Secret post' ); // A locked post exposes neither its edit link nor its Yoast meta. Functions\expect( 'get_edit_post_link' )->never(); @@ -138,7 +148,12 @@ public function test_get_posts_reports_total_from_found_posts() { $this->post_editability_resolver->expects( 'resolve' )->with( [ 7 ] )->andReturn( [ 7 => true ] ); - Functions\expect( 'get_post' )->once()->andReturn( (object) [ 'post_status' => 'draft' ] ); + Functions\expect( 'get_post' )->once()->andReturn( + (object) [ + 'post_status' => 'draft', + 'post_type' => 'post', + ], + ); Functions\expect( 'get_the_title' )->once()->andReturn( 'Hello world' ); Functions\expect( 'get_edit_post_link' )->once()->andReturn( 'edit' ); Functions\expect( 'get_post_meta' )->times( 7 )->andReturn( '' ); @@ -149,6 +164,59 @@ public function test_get_posts_reports_total_from_found_posts() { $this->assertSame( 3, $result['total_pages'] ); } + /** + * Tests that the SEO title and meta description fall back to the resolved template when the stored + * values are empty, and that the post is not flagged as needing improvement. + * + * @return void + */ + public function test_get_posts_resolves_template_when_stored_values_are_empty() { + $meta = [ + '_yoast_wpseo_focuskw' => '', + '_yoast_wpseo_title' => '', + '_yoast_wpseo_metadesc' => '', + '_yoast_wpseo_opengraph-title' => 'Social hello', + '_yoast_wpseo_opengraph-description' => 'Social description.', + '_yoast_wpseo_seo_title_score' => '0', + '_yoast_wpseo_meta_description_score' => '0', + ]; + + $this->stub_run_query( [ 7 ], 1 ); + + $this->post_editability_resolver->expects( 'resolve' )->with( [ 7 ] )->andReturn( [ 7 => true ] ); + + Functions\expect( 'get_post' )->once()->with( 7 )->andReturn( + (object) [ + 'post_status' => 'draft', + 'post_type' => 'page', + ], + ); + Functions\expect( 'get_the_title' )->once()->with( 7 )->andReturn( 'A page' ); + Functions\expect( 'get_edit_post_link' )->once()->with( 7, 'raw' )->andReturn( 'post.php?post=7&action=edit' ); + Functions\expect( 'get_post_meta' ) + ->times( 7 ) + ->andReturnUsing( + static function ( $post_id, $key ) use ( $meta ) { + return $meta[ $key ]; + }, + ); + + $this->default_template_resolver->allows( 'resolve_seo_title' ) + ->with( 7, 'page', '' ) + ->andReturn( 'Page title from template' ); + $this->default_template_resolver->allows( 'resolve_meta_description' ) + ->with( 7, 'page', '' ) + ->andReturn( 'Page description from template' ); + + $result = $this->instance->get_posts( new Posts_Query( 'page', 1, 20, '', self::STATUSES ) )->to_array(); + $post = $result['posts'][0]; + + $this->assertSame( 'Page title from template', $post['seo_title'] ); + $this->assertSame( 'Page description from template', $post['meta_description'] ); + $this->assertFalse( $post['needs_improvement']['seo_title'] ); + $this->assertFalse( $post['needs_improvement']['meta_description'] ); + } + /** * Stubs run_query so it returns a WP_Query with the given post IDs and total. * diff --git a/tests/Unit/Doubles/Bulk_Editor/Post_Meta_Posts_Collector_Double.php b/tests/Unit/Doubles/Bulk_Editor/Post_Meta_Posts_Collector_Double.php index 620854f8839..e79cb618018 100644 --- a/tests/Unit/Doubles/Bulk_Editor/Post_Meta_Posts_Collector_Double.php +++ b/tests/Unit/Doubles/Bulk_Editor/Post_Meta_Posts_Collector_Double.php @@ -2,13 +2,31 @@ namespace Yoast\WP\SEO\Tests\Unit\Doubles\Bulk_Editor; +use Yoast\WP\SEO\Bulk_Editor\Infrastructure\Posts\Default_Template_Resolver; +use Yoast\WP\SEO\Bulk_Editor\Infrastructure\Posts\Post_Editability_Resolver; use Yoast\WP\SEO\Bulk_Editor\Infrastructure\Posts\Post_Meta_Posts_Collector; /** * Test double that exposes the collector's protected "needs improvement" WHERE builder. + * + * Accepts only the two dependencies it actually exercises (editability + template resolvers); tests + * that need the full constructor should construct the production class directly. */ class Post_Meta_Posts_Collector_Double extends Post_Meta_Posts_Collector { + /** + * The constructor. + * + * @param Post_Editability_Resolver $post_editability_resolver The resolver for the per-post edit permission. + * @param Default_Template_Resolver $default_template_resolver The resolver for the default SEO title / meta description template. + */ + public function __construct( + Post_Editability_Resolver $post_editability_resolver, + Default_Template_Resolver $default_template_resolver + ) { + parent::__construct( $post_editability_resolver, $default_template_resolver ); + } + /** * Exposes build_needs_improvement_where for testing. * From 7f52065cc8ae884d9e47671b6de20c46094cb24d Mon Sep 17 00:00:00 2001 From: vraja-pro Date: Tue, 4 Aug 2026 15:09:27 +0300 Subject: [PATCH 02/34] refactor: remove redundant constructor from Post_Meta_Posts_Collector_Double The explicit constructor was a pass-through to parent::__construct() with the same signature, which PHP already provides implicitly. Removing it keeps the double lean and avoids a stale comment when the parent signature next changes. Co-Authored-By: Claude Sonnet 4.6 --- .../Post_Meta_Posts_Collector_Double.php | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/tests/Unit/Doubles/Bulk_Editor/Post_Meta_Posts_Collector_Double.php b/tests/Unit/Doubles/Bulk_Editor/Post_Meta_Posts_Collector_Double.php index e79cb618018..620854f8839 100644 --- a/tests/Unit/Doubles/Bulk_Editor/Post_Meta_Posts_Collector_Double.php +++ b/tests/Unit/Doubles/Bulk_Editor/Post_Meta_Posts_Collector_Double.php @@ -2,31 +2,13 @@ namespace Yoast\WP\SEO\Tests\Unit\Doubles\Bulk_Editor; -use Yoast\WP\SEO\Bulk_Editor\Infrastructure\Posts\Default_Template_Resolver; -use Yoast\WP\SEO\Bulk_Editor\Infrastructure\Posts\Post_Editability_Resolver; use Yoast\WP\SEO\Bulk_Editor\Infrastructure\Posts\Post_Meta_Posts_Collector; /** * Test double that exposes the collector's protected "needs improvement" WHERE builder. - * - * Accepts only the two dependencies it actually exercises (editability + template resolvers); tests - * that need the full constructor should construct the production class directly. */ class Post_Meta_Posts_Collector_Double extends Post_Meta_Posts_Collector { - /** - * The constructor. - * - * @param Post_Editability_Resolver $post_editability_resolver The resolver for the per-post edit permission. - * @param Default_Template_Resolver $default_template_resolver The resolver for the default SEO title / meta description template. - */ - public function __construct( - Post_Editability_Resolver $post_editability_resolver, - Default_Template_Resolver $default_template_resolver - ) { - parent::__construct( $post_editability_resolver, $default_template_resolver ); - } - /** * Exposes build_needs_improvement_where for testing. * From a4ea615a33baa7c995b5f046edf4f2a98b14bf1d Mon Sep 17 00:00:00 2001 From: vraja-pro Date: Tue, 4 Aug 2026 15:30:26 +0300 Subject: [PATCH 03/34] feat(bulk-editor): extend default template resolution to social title and description Extends the Default_Template_Resolver with resolve_social_title() and resolve_social_description(), following the same pattern as the existing SEO title/meta description methods. The option keys are social-title-{post_type} (installation default %%title%%) and social-description-{post_type} (no installation default), mirroring the WPSEO_Option_Titles enriched defaults. Both collectors now resolve all four fields before building the Post domain object, and the needs_improvement verdict for social_title and social_description also uses the resolved values. The build_needs_improvement() signature in Indexable_Posts_Collector is simplified to accept a single resolved-values map instead of individual parameters. Co-Authored-By: Claude Sonnet 4.6 --- .../posts/default-template-resolver.php | 54 +++++++++++- .../posts/indexable-posts-collector.php | 39 ++++----- .../posts/post-meta-posts-collector.php | 6 +- .../Resolve_Social_Description_Test.php | 66 ++++++++++++++ .../Resolve_Social_Title_Test.php | 86 +++++++++++++++++++ ...bstract_Indexable_Posts_Collector_Test.php | 2 + .../Get_Posts_Test.php | 43 ++++++++++ ...bstract_Post_Meta_Posts_Collector_Test.php | 2 + .../Get_Posts_Test.php | 53 ++++++++++++ 9 files changed, 326 insertions(+), 25 deletions(-) create mode 100644 tests/Unit/Bulk_Editor/Infrastructure/Posts/Default_Template_Resolver/Resolve_Social_Description_Test.php create mode 100644 tests/Unit/Bulk_Editor/Infrastructure/Posts/Default_Template_Resolver/Resolve_Social_Title_Test.php diff --git a/src/bulk-editor/infrastructure/posts/default-template-resolver.php b/src/bulk-editor/infrastructure/posts/default-template-resolver.php index e51a6f2fbc3..7c59ef887d6 100644 --- a/src/bulk-editor/infrastructure/posts/default-template-resolver.php +++ b/src/bulk-editor/infrastructure/posts/default-template-resolver.php @@ -6,8 +6,8 @@ use Yoast\WP\SEO\Helpers\Options_Helper; /** - * Resolves a post's SEO title / meta description from the post type's default template when the - * stored value is empty, matching the single-post editor's fallback behaviour. + * Resolves a post's SEO/social fields from the post type's default template when the stored value + * is empty, matching the single-post editor's fallback behaviour. */ class Default_Template_Resolver { @@ -76,4 +76,54 @@ public function resolve_meta_description( int $post_id, string $post_type, strin return (string) \wpseo_replace_vars( $template, \get_post( $post_id ) ); } + + /** + * Returns the social title for a post, falling back to the post type's configured template when empty. + * + * Priority mirrors the presentation layer: stored value → user-configured post type template + * (SEO > Settings, `social-title-{post_type}`) → installation default (typically `%%title%%`). + * + * @param int $post_id The post ID. + * @param string $post_type The post type slug. + * @param string $stored_value The raw stored social title (empty string when never explicitly saved). + * + * @return string The resolved social title. + */ + public function resolve_social_title( int $post_id, string $post_type, string $stored_value ): string { + if ( $stored_value !== '' ) { + return $stored_value; + } + + $template = (string) $this->options_helper->get( 'social-title-' . $post_type, '' ); + if ( $template === '' ) { + $template = (string) $this->options_helper->get_title_default( 'social-title-' . $post_type ); + } + if ( $template === '' ) { + return ''; + } + + return (string) \wpseo_replace_vars( $template, \get_post( $post_id ) ); + } + + /** + * Returns the social description for a post, falling back to the post type's configured template when empty. + * + * @param int $post_id The post ID. + * @param string $post_type The post type slug. + * @param string $stored_value The raw stored social description (empty string when never explicitly saved). + * + * @return string The resolved social description. + */ + public function resolve_social_description( int $post_id, string $post_type, string $stored_value ): string { + if ( $stored_value !== '' ) { + return $stored_value; + } + + $template = (string) $this->options_helper->get( 'social-description-' . $post_type, '' ); + if ( $template === '' ) { + return ''; + } + + return (string) \wpseo_replace_vars( $template, \get_post( $post_id ) ); + } } diff --git a/src/bulk-editor/infrastructure/posts/indexable-posts-collector.php b/src/bulk-editor/infrastructure/posts/indexable-posts-collector.php index 65b2328f886..182612ed574 100644 --- a/src/bulk-editor/infrastructure/posts/indexable-posts-collector.php +++ b/src/bulk-editor/infrastructure/posts/indexable-posts-collector.php @@ -274,9 +274,13 @@ private function build_post( Indexable $indexable, bool $editable, bool $scores_ return new Post( $object_id, $title, (string) $indexable->post_status, '', '', '', '', '', '', false ); } - $post_type = (string) $indexable->object_sub_type; - $seo_title = $this->default_template_resolver->resolve_seo_title( $object_id, $post_type, (string) $indexable->title ); - $meta_description = $this->default_template_resolver->resolve_meta_description( $object_id, $post_type, (string) $indexable->description ); + $post_type = (string) $indexable->object_sub_type; + $resolved_values = [ + 'seo_title' => $this->default_template_resolver->resolve_seo_title( $object_id, $post_type, (string) $indexable->title ), + 'meta_description' => $this->default_template_resolver->resolve_meta_description( $object_id, $post_type, (string) $indexable->description ), + 'social_title' => $this->default_template_resolver->resolve_social_title( $object_id, $post_type, (string) $indexable->open_graph_title ), + 'social_description' => $this->default_template_resolver->resolve_social_description( $object_id, $post_type, (string) $indexable->open_graph_description ), + ]; return new Post( $object_id, @@ -284,12 +288,12 @@ private function build_post( Indexable $indexable, bool $editable, bool $scores_ (string) $indexable->post_status, (string) \get_edit_post_link( $object_id, 'raw' ), (string) $indexable->primary_focus_keyword, - $seo_title, - $meta_description, - (string) $indexable->open_graph_title, - (string) $indexable->open_graph_description, + $resolved_values['seo_title'], + $resolved_values['meta_description'], + $resolved_values['social_title'], + $resolved_values['social_description'], true, - $this->build_needs_improvement( $indexable, $scores_enabled, $seo_title, $meta_description ), + $this->build_needs_improvement( $indexable, $scores_enabled, $resolved_values ), ); } @@ -297,23 +301,16 @@ private function build_post( Indexable $indexable, bool $editable, bool $scores_ * Builds the per-field needs-improvement verdict for a post, keyed by field param. * * A field needs improvement when its value is empty, or when its score falls in the bad/ok range. - * The SEO title and meta description use their already-resolved values (which may have been filled - * in from the post type's default template) so a post with a non-empty template is not flagged - * as needing improvement merely because its stored value was never explicitly saved. + * All four display values are passed in already-resolved so that a post whose stored value is empty + * but whose post type has a configured default template is not incorrectly flagged. * - * @param Indexable $indexable The indexable. - * @param bool $scores_enabled Whether the per-field scores may back the verdict. - * @param string $seo_title The resolved SEO title (may differ from the raw indexable value). - * @param string $meta_description The resolved meta description (may differ from the raw indexable value). + * @param Indexable $indexable The indexable. + * @param bool $scores_enabled Whether the per-field scores may back the verdict. + * @param array $resolved_values The resolved display values, keyed by field param. * * @return array Whether each field needs improvement, keyed by field param. */ - private function build_needs_improvement( Indexable $indexable, bool $scores_enabled, string $seo_title, string $meta_description ): array { - $resolved_values = [ - 'seo_title' => $seo_title, - 'meta_description' => $meta_description, - ]; - + private function build_needs_improvement( Indexable $indexable, bool $scores_enabled, array $resolved_values ): array { $needs_improvement = []; foreach ( self::FIELD_COLUMNS as $field => $column ) { $value = ( $resolved_values[ $field ] ?? (string) $indexable->{$column} ); diff --git a/src/bulk-editor/infrastructure/posts/post-meta-posts-collector.php b/src/bulk-editor/infrastructure/posts/post-meta-posts-collector.php index dec287794ae..0f92fddfc94 100644 --- a/src/bulk-editor/infrastructure/posts/post-meta-posts-collector.php +++ b/src/bulk-editor/infrastructure/posts/post-meta-posts-collector.php @@ -238,8 +238,10 @@ private function build_post( int $post_id, bool $editable, bool $scores_enabled } // Fall back to the post type's default template when the stored value is empty. - $fields['seo_title'] = $this->default_template_resolver->resolve_seo_title( $post_id, $post_type, $fields['seo_title'] ); - $fields['meta_description'] = $this->default_template_resolver->resolve_meta_description( $post_id, $post_type, $fields['meta_description'] ); + $fields['seo_title'] = $this->default_template_resolver->resolve_seo_title( $post_id, $post_type, $fields['seo_title'] ); + $fields['meta_description'] = $this->default_template_resolver->resolve_meta_description( $post_id, $post_type, $fields['meta_description'] ); + $fields['social_title'] = $this->default_template_resolver->resolve_social_title( $post_id, $post_type, $fields['social_title'] ); + $fields['social_description'] = $this->default_template_resolver->resolve_social_description( $post_id, $post_type, $fields['social_description'] ); return new Post( $post_id, diff --git a/tests/Unit/Bulk_Editor/Infrastructure/Posts/Default_Template_Resolver/Resolve_Social_Description_Test.php b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Default_Template_Resolver/Resolve_Social_Description_Test.php new file mode 100644 index 00000000000..3eed36e2838 --- /dev/null +++ b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Default_Template_Resolver/Resolve_Social_Description_Test.php @@ -0,0 +1,66 @@ +options_helper->expects( 'get' )->never(); + + $result = $this->instance->resolve_social_description( 7, 'post', 'My explicit social description.' ); + + $this->assertSame( 'My explicit social description.', $result ); + } + + /** + * Tests that the user-configured post type template is resolved when the stored value is empty. + * + * @return void + */ + public function test_resolves_from_configured_template_when_stored_value_is_empty() { + $post = (object) [ 'ID' => 7 ]; + + $this->options_helper->expects( 'get' )->with( 'social-description-post', '' )->andReturn( '%%excerpt%%' ); + + Functions\expect( 'get_post' )->once()->with( 7 )->andReturn( $post ); + Functions\expect( 'wpseo_replace_vars' )->once()->with( '%%excerpt%%', $post )->andReturn( 'The post excerpt.' ); + + $result = $this->instance->resolve_social_description( 7, 'post', '' ); + + $this->assertSame( 'The post excerpt.', $result ); + } + + /** + * Tests that an empty string is returned when no template is configured for the post type. + * + * Unlike social title, there is no installation-level default for social description. + * + * @return void + */ + public function test_returns_empty_when_no_template_is_configured() { + $this->options_helper->expects( 'get' )->with( 'social-description-page', '' )->andReturn( '' ); + + Functions\expect( 'get_post' )->never(); + Functions\expect( 'wpseo_replace_vars' )->never(); + + $result = $this->instance->resolve_social_description( 7, 'page', '' ); + + $this->assertSame( '', $result ); + } +} diff --git a/tests/Unit/Bulk_Editor/Infrastructure/Posts/Default_Template_Resolver/Resolve_Social_Title_Test.php b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Default_Template_Resolver/Resolve_Social_Title_Test.php new file mode 100644 index 00000000000..37a416e7b28 --- /dev/null +++ b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Default_Template_Resolver/Resolve_Social_Title_Test.php @@ -0,0 +1,86 @@ +options_helper->expects( 'get' )->never(); + $this->options_helper->expects( 'get_title_default' )->never(); + + $result = $this->instance->resolve_social_title( 7, 'post', 'My explicit social title' ); + + $this->assertSame( 'My explicit social title', $result ); + } + + /** + * Tests that the user-configured post type template is resolved when the stored value is empty. + * + * @return void + */ + public function test_resolves_from_configured_template_when_stored_value_is_empty() { + $post = (object) [ 'ID' => 7 ]; + + $this->options_helper->expects( 'get' )->with( 'social-title-post', '' )->andReturn( '%%title%%' ); + $this->options_helper->expects( 'get_title_default' )->never(); + + Functions\expect( 'get_post' )->once()->with( 7 )->andReturn( $post ); + Functions\expect( 'wpseo_replace_vars' )->once()->with( '%%title%%', $post )->andReturn( 'My post' ); + + $result = $this->instance->resolve_social_title( 7, 'post', '' ); + + $this->assertSame( 'My post', $result ); + } + + /** + * Tests that the installation default is tried when the user has not configured a template. + * + * @return void + */ + public function test_resolves_from_default_template_when_configured_template_is_empty() { + $post = (object) [ 'ID' => 7 ]; + + $this->options_helper->expects( 'get' )->with( 'social-title-page', '' )->andReturn( '' ); + $this->options_helper->expects( 'get_title_default' )->with( 'social-title-page' )->andReturn( '%%title%%' ); + + Functions\expect( 'get_post' )->once()->with( 7 )->andReturn( $post ); + Functions\expect( 'wpseo_replace_vars' )->once()->with( '%%title%%', $post )->andReturn( 'A page' ); + + $result = $this->instance->resolve_social_title( 7, 'page', '' ); + + $this->assertSame( 'A page', $result ); + } + + /** + * Tests that an empty string is returned when neither a configured template nor an installation default exists. + * + * @return void + */ + public function test_returns_empty_when_no_template_exists() { + $this->options_helper->expects( 'get' )->with( 'social-title-post', '' )->andReturn( '' ); + $this->options_helper->expects( 'get_title_default' )->with( 'social-title-post' )->andReturn( '' ); + + Functions\expect( 'get_post' )->never(); + Functions\expect( 'wpseo_replace_vars' )->never(); + + $result = $this->instance->resolve_social_title( 7, 'post', '' ); + + $this->assertSame( '', $result ); + } +} diff --git a/tests/Unit/Bulk_Editor/Infrastructure/Posts/Indexable_Posts_Collector/Abstract_Indexable_Posts_Collector_Test.php b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Indexable_Posts_Collector/Abstract_Indexable_Posts_Collector_Test.php index 9046acd020e..e5be01eaaf9 100644 --- a/tests/Unit/Bulk_Editor/Infrastructure/Posts/Indexable_Posts_Collector/Abstract_Indexable_Posts_Collector_Test.php +++ b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Indexable_Posts_Collector/Abstract_Indexable_Posts_Collector_Test.php @@ -61,6 +61,8 @@ protected function set_up() { // Pass the stored value through unchanged by default; individual tests override when needed. $this->default_template_resolver->allows( 'resolve_seo_title' )->andReturnArg( 2 )->byDefault(); $this->default_template_resolver->allows( 'resolve_meta_description' )->andReturnArg( 2 )->byDefault(); + $this->default_template_resolver->allows( 'resolve_social_title' )->andReturnArg( 2 )->byDefault(); + $this->default_template_resolver->allows( 'resolve_social_description' )->andReturnArg( 2 )->byDefault(); $this->instance = new Indexable_Posts_Collector( $this->indexable_repository, diff --git a/tests/Unit/Bulk_Editor/Infrastructure/Posts/Indexable_Posts_Collector/Get_Posts_Test.php b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Indexable_Posts_Collector/Get_Posts_Test.php index 56df15be13d..c3a6d1b0e4d 100644 --- a/tests/Unit/Bulk_Editor/Infrastructure/Posts/Indexable_Posts_Collector/Get_Posts_Test.php +++ b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Indexable_Posts_Collector/Get_Posts_Test.php @@ -133,6 +133,49 @@ public function test_get_posts_resolves_template_when_stored_values_are_empty() $this->assertFalse( $post['needs_improvement']['meta_description'] ); } + /** + * Tests that the social title and social description fall back to the resolved template when the + * stored values are empty, and that the post is not flagged as needing improvement. + * + * @return void + */ + public function test_get_posts_resolves_social_template_when_stored_values_are_empty() { + $indexable = new Indexable_Mock(); + $indexable->object_id = 7; + $indexable->object_sub_type = 'post'; + $indexable->post_status = 'publish'; + $indexable->primary_focus_keyword = ''; + $indexable->title = 'Explicit SEO title'; + $indexable->description = 'Explicit meta description.'; + $indexable->open_graph_title = ''; + $indexable->open_graph_description = ''; + $indexable->seo_title_score = 0; + $indexable->meta_description_score = 0; + + $query = $this->stub_page_query( [ $indexable ] ); + $query->expects( 'count' )->never(); + + $this->post_editability_resolver->expects( 'resolve' )->with( [ 7 ] )->andReturn( [ 7 => true ] ); + + $this->default_template_resolver->expects( 'resolve_social_title' ) + ->with( 7, 'post', '' ) + ->andReturn( 'Social title from template' ); + $this->default_template_resolver->expects( 'resolve_social_description' ) + ->with( 7, 'post', '' ) + ->andReturn( 'Social description from template' ); + + Functions\expect( 'get_the_title' )->once()->with( 7 )->andReturn( 'A post' ); + Functions\expect( 'get_edit_post_link' )->once()->with( 7, 'raw' )->andReturn( 'post.php?post=7&action=edit' ); + + $result = $this->instance->get_posts( new Posts_Query( 'page', 1, 20, '', self::STATUSES ) )->to_array(); + $post = $result['posts'][0]; + + $this->assertSame( 'Social title from template', $post['social_title'] ); + $this->assertSame( 'Social description from template', $post['social_description'] ); + $this->assertFalse( $post['needs_improvement']['social_title'] ); + $this->assertFalse( $post['needs_improvement']['social_description'] ); + } + /** * Tests that a non-editable post is returned locked and without its SEO data. * diff --git a/tests/Unit/Bulk_Editor/Infrastructure/Posts/Post_Meta_Posts_Collector/Abstract_Post_Meta_Posts_Collector_Test.php b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Post_Meta_Posts_Collector/Abstract_Post_Meta_Posts_Collector_Test.php index 793aea60984..031be776bb8 100644 --- a/tests/Unit/Bulk_Editor/Infrastructure/Posts/Post_Meta_Posts_Collector/Abstract_Post_Meta_Posts_Collector_Test.php +++ b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Post_Meta_Posts_Collector/Abstract_Post_Meta_Posts_Collector_Test.php @@ -54,6 +54,8 @@ protected function set_up() { // Pass the stored value through unchanged by default; individual tests override when needed. $this->default_template_resolver->allows( 'resolve_seo_title' )->andReturnArg( 2 )->byDefault(); $this->default_template_resolver->allows( 'resolve_meta_description' )->andReturnArg( 2 )->byDefault(); + $this->default_template_resolver->allows( 'resolve_social_title' )->andReturnArg( 2 )->byDefault(); + $this->default_template_resolver->allows( 'resolve_social_description' )->andReturnArg( 2 )->byDefault(); $this->instance = Mockery::mock( Post_Meta_Posts_Collector::class, diff --git a/tests/Unit/Bulk_Editor/Infrastructure/Posts/Post_Meta_Posts_Collector/Get_Posts_Test.php b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Post_Meta_Posts_Collector/Get_Posts_Test.php index 6178cce860d..b04340d3c79 100644 --- a/tests/Unit/Bulk_Editor/Infrastructure/Posts/Post_Meta_Posts_Collector/Get_Posts_Test.php +++ b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Post_Meta_Posts_Collector/Get_Posts_Test.php @@ -217,6 +217,59 @@ static function ( $post_id, $key ) use ( $meta ) { $this->assertFalse( $post['needs_improvement']['meta_description'] ); } + /** + * Tests that the social title and social description fall back to the resolved template when the + * stored values are empty, and that the post is not flagged as needing improvement. + * + * @return void + */ + public function test_get_posts_resolves_social_template_when_stored_values_are_empty() { + $meta = [ + '_yoast_wpseo_focuskw' => '', + '_yoast_wpseo_title' => 'Explicit SEO title', + '_yoast_wpseo_metadesc' => 'Explicit meta description.', + '_yoast_wpseo_opengraph-title' => '', + '_yoast_wpseo_opengraph-description' => '', + '_yoast_wpseo_seo_title_score' => '0', + '_yoast_wpseo_meta_description_score' => '0', + ]; + + $this->stub_run_query( [ 7 ], 1 ); + + $this->post_editability_resolver->expects( 'resolve' )->with( [ 7 ] )->andReturn( [ 7 => true ] ); + + Functions\expect( 'get_post' )->once()->with( 7 )->andReturn( + (object) [ + 'post_status' => 'publish', + 'post_type' => 'post', + ], + ); + Functions\expect( 'get_the_title' )->once()->with( 7 )->andReturn( 'A post' ); + Functions\expect( 'get_edit_post_link' )->once()->with( 7, 'raw' )->andReturn( 'post.php?post=7&action=edit' ); + Functions\expect( 'get_post_meta' ) + ->times( 7 ) + ->andReturnUsing( + static function ( $post_id, $key ) use ( $meta ) { + return $meta[ $key ]; + }, + ); + + $this->default_template_resolver->allows( 'resolve_social_title' ) + ->with( 7, 'post', '' ) + ->andReturn( 'Social title from template' ); + $this->default_template_resolver->allows( 'resolve_social_description' ) + ->with( 7, 'post', '' ) + ->andReturn( 'Social description from template' ); + + $result = $this->instance->get_posts( new Posts_Query( 'page', 1, 20, '', self::STATUSES ) )->to_array(); + $post = $result['posts'][0]; + + $this->assertSame( 'Social title from template', $post['social_title'] ); + $this->assertSame( 'Social description from template', $post['social_description'] ); + $this->assertFalse( $post['needs_improvement']['social_title'] ); + $this->assertFalse( $post['needs_improvement']['social_description'] ); + } + /** * Stubs run_query so it returns a WP_Query with the given post IDs and total. * From edd499ae5047bed3c620a461939f3385684f3bc3 Mon Sep 17 00:00:00 2001 From: vraja-pro Date: Wed, 5 Aug 2026 16:10:02 +0300 Subject: [PATCH 04/34] fix(bulk-editor): gate social title/description resolution on OpenGraph and use filter Mirrors Social_Data_Provider: only resolve a template when opengraph is enabled, and delegate to apply_filters( 'wpseo_social_template_post_type' ) instead of reading the option directly. On Free the filter returns '' (no callback registered), so social fields never get a spurious resolved value from the %%title%% default that users cannot see or change. Premium's callback continues to supply the configured template. Co-Authored-By: Claude Sonnet 4.6 --- .../posts/default-template-resolver.php | 23 +++++-- .../Resolve_Social_Description_Test.php | 58 +++++++++++++---- .../Resolve_Social_Title_Test.php | 64 +++++++++++-------- 3 files changed, 98 insertions(+), 47 deletions(-) diff --git a/src/bulk-editor/infrastructure/posts/default-template-resolver.php b/src/bulk-editor/infrastructure/posts/default-template-resolver.php index 7c59ef887d6..0b657f8e84f 100644 --- a/src/bulk-editor/infrastructure/posts/default-template-resolver.php +++ b/src/bulk-editor/infrastructure/posts/default-template-resolver.php @@ -80,8 +80,10 @@ public function resolve_meta_description( int $post_id, string $post_type, strin /** * Returns the social title for a post, falling back to the post type's configured template when empty. * - * Priority mirrors the presentation layer: stored value → user-configured post type template - * (SEO > Settings, `social-title-{post_type}`) → installation default (typically `%%title%%`). + * Mirrors `Social_Data_Provider::get_social_title_template()`: only resolves a template when + * OpenGraph is enabled, and delegates to the `wpseo_social_template_post_type` filter so that + * Premium can supply a value while Free — which cannot configure this setting — always gets an + * empty string. * * @param int $post_id The post ID. * @param string $post_type The post type slug. @@ -94,10 +96,11 @@ public function resolve_social_title( int $post_id, string $post_type, string $s return $stored_value; } - $template = (string) $this->options_helper->get( 'social-title-' . $post_type, '' ); - if ( $template === '' ) { - $template = (string) $this->options_helper->get_title_default( 'social-title-' . $post_type ); + if ( $this->options_helper->get( 'opengraph', false ) !== true ) { + return ''; } + + $template = (string) \apply_filters( 'wpseo_social_template_post_type', '', 'title', $post_type ); if ( $template === '' ) { return ''; } @@ -108,6 +111,10 @@ public function resolve_social_title( int $post_id, string $post_type, string $s /** * Returns the social description for a post, falling back to the post type's configured template when empty. * + * Mirrors `Social_Data_Provider::get_social_description_template()`: only resolves a template when + * OpenGraph is enabled, and delegates to the `wpseo_social_template_post_type` filter so that + * Premium can supply a value while Free always gets an empty string. + * * @param int $post_id The post ID. * @param string $post_type The post type slug. * @param string $stored_value The raw stored social description (empty string when never explicitly saved). @@ -119,7 +126,11 @@ public function resolve_social_description( int $post_id, string $post_type, str return $stored_value; } - $template = (string) $this->options_helper->get( 'social-description-' . $post_type, '' ); + if ( $this->options_helper->get( 'opengraph', false ) !== true ) { + return ''; + } + + $template = (string) \apply_filters( 'wpseo_social_template_post_type', '', 'description', $post_type ); if ( $template === '' ) { return ''; } diff --git a/tests/Unit/Bulk_Editor/Infrastructure/Posts/Default_Template_Resolver/Resolve_Social_Description_Test.php b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Default_Template_Resolver/Resolve_Social_Description_Test.php index 3eed36e2838..992557607ba 100644 --- a/tests/Unit/Bulk_Editor/Infrastructure/Posts/Default_Template_Resolver/Resolve_Social_Description_Test.php +++ b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Default_Template_Resolver/Resolve_Social_Description_Test.php @@ -4,6 +4,7 @@ // phpcs:disable Yoast.NamingConventions.NamespaceName.MaxExceeded namespace Yoast\WP\SEO\Tests\Unit\Bulk_Editor\Infrastructure\Posts\Default_Template_Resolver; +use Brain\Monkey\Filters; use Brain\Monkey\Functions; /** @@ -16,7 +17,7 @@ final class Resolve_Social_Description_Test extends Abstract_Default_Template_Resolver_Test { /** - * Tests that a non-empty stored value is returned unchanged without touching the options. + * Tests that a non-empty stored value is returned unchanged without touching options or filters. * * @return void */ @@ -29,38 +30,67 @@ public function test_returns_stored_value_when_not_empty() { } /** - * Tests that the user-configured post type template is resolved when the stored value is empty. + * Tests that an empty string is returned when OpenGraph is disabled. * * @return void */ - public function test_resolves_from_configured_template_when_stored_value_is_empty() { - $post = (object) [ 'ID' => 7 ]; - - $this->options_helper->expects( 'get' )->with( 'social-description-post', '' )->andReturn( '%%excerpt%%' ); + public function test_returns_empty_when_opengraph_disabled() { + $this->options_helper->expects( 'get' )->with( 'opengraph', false )->andReturn( false ); - Functions\expect( 'get_post' )->once()->with( 7 )->andReturn( $post ); - Functions\expect( 'wpseo_replace_vars' )->once()->with( '%%excerpt%%', $post )->andReturn( 'The post excerpt.' ); + Functions\expect( 'apply_filters' )->never(); + Functions\expect( 'wpseo_replace_vars' )->never(); $result = $this->instance->resolve_social_description( 7, 'post', '' ); - $this->assertSame( 'The post excerpt.', $result ); + $this->assertSame( '', $result ); } /** - * Tests that an empty string is returned when no template is configured for the post type. + * Tests that an empty string is returned when OpenGraph is enabled but the filter returns no template. * - * Unlike social title, there is no installation-level default for social description. + * This is the expected behaviour on Free, where no callback is registered for + * `wpseo_social_template_post_type` and the filter therefore returns the default empty string. * * @return void */ - public function test_returns_empty_when_no_template_is_configured() { - $this->options_helper->expects( 'get' )->with( 'social-description-page', '' )->andReturn( '' ); + public function test_returns_empty_when_filter_returns_empty_template() { + $this->options_helper->expects( 'get' )->with( 'opengraph', false )->andReturn( true ); + + Filters\expectApplied( 'wpseo_social_template_post_type' ) + ->once() + ->with( '', 'description', 'post' ) + ->andReturn( '' ); Functions\expect( 'get_post' )->never(); Functions\expect( 'wpseo_replace_vars' )->never(); - $result = $this->instance->resolve_social_description( 7, 'page', '' ); + $result = $this->instance->resolve_social_description( 7, 'post', '' ); $this->assertSame( '', $result ); } + + /** + * Tests that the filter-provided template is resolved when OpenGraph is enabled. + * + * This is the expected behaviour on Premium, where a callback supplies the configured template. + * + * @return void + */ + public function test_resolves_from_filter_template_when_opengraph_enabled() { + $post = (object) [ 'ID' => 7 ]; + + $this->options_helper->expects( 'get' )->with( 'opengraph', false )->andReturn( true ); + + Filters\expectApplied( 'wpseo_social_template_post_type' ) + ->once() + ->with( '', 'description', 'post' ) + ->andReturn( '%%excerpt%%' ); + + Functions\expect( 'get_post' )->once()->with( 7 )->andReturn( $post ); + Functions\expect( 'wpseo_replace_vars' )->once()->with( '%%excerpt%%', $post )->andReturn( 'The post excerpt.' ); + + $result = $this->instance->resolve_social_description( 7, 'post', '' ); + + $this->assertSame( 'The post excerpt.', $result ); + } } diff --git a/tests/Unit/Bulk_Editor/Infrastructure/Posts/Default_Template_Resolver/Resolve_Social_Title_Test.php b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Default_Template_Resolver/Resolve_Social_Title_Test.php index 37a416e7b28..99b1de0ca38 100644 --- a/tests/Unit/Bulk_Editor/Infrastructure/Posts/Default_Template_Resolver/Resolve_Social_Title_Test.php +++ b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Default_Template_Resolver/Resolve_Social_Title_Test.php @@ -4,6 +4,7 @@ // phpcs:disable Yoast.NamingConventions.NamespaceName.MaxExceeded namespace Yoast\WP\SEO\Tests\Unit\Bulk_Editor\Infrastructure\Posts\Default_Template_Resolver; +use Brain\Monkey\Filters; use Brain\Monkey\Functions; /** @@ -16,13 +17,12 @@ final class Resolve_Social_Title_Test extends Abstract_Default_Template_Resolver_Test { /** - * Tests that a non-empty stored value is returned unchanged without touching the options. + * Tests that a non-empty stored value is returned unchanged without touching options or filters. * * @return void */ public function test_returns_stored_value_when_not_empty() { $this->options_helper->expects( 'get' )->never(); - $this->options_helper->expects( 'get_title_default' )->never(); $result = $this->instance->resolve_social_title( 7, 'post', 'My explicit social title' ); @@ -30,57 +30,67 @@ public function test_returns_stored_value_when_not_empty() { } /** - * Tests that the user-configured post type template is resolved when the stored value is empty. + * Tests that an empty string is returned when OpenGraph is disabled. * * @return void */ - public function test_resolves_from_configured_template_when_stored_value_is_empty() { - $post = (object) [ 'ID' => 7 ]; - - $this->options_helper->expects( 'get' )->with( 'social-title-post', '' )->andReturn( '%%title%%' ); - $this->options_helper->expects( 'get_title_default' )->never(); + public function test_returns_empty_when_opengraph_disabled() { + $this->options_helper->expects( 'get' )->with( 'opengraph', false )->andReturn( false ); - Functions\expect( 'get_post' )->once()->with( 7 )->andReturn( $post ); - Functions\expect( 'wpseo_replace_vars' )->once()->with( '%%title%%', $post )->andReturn( 'My post' ); + Functions\expect( 'apply_filters' )->never(); + Functions\expect( 'wpseo_replace_vars' )->never(); $result = $this->instance->resolve_social_title( 7, 'post', '' ); - $this->assertSame( 'My post', $result ); + $this->assertSame( '', $result ); } /** - * Tests that the installation default is tried when the user has not configured a template. + * Tests that an empty string is returned when OpenGraph is enabled but the filter returns no template. + * + * This is the expected behaviour on Free, where no callback is registered for + * `wpseo_social_template_post_type` and the filter therefore returns the default empty string. * * @return void */ - public function test_resolves_from_default_template_when_configured_template_is_empty() { - $post = (object) [ 'ID' => 7 ]; + public function test_returns_empty_when_filter_returns_empty_template() { + $this->options_helper->expects( 'get' )->with( 'opengraph', false )->andReturn( true ); - $this->options_helper->expects( 'get' )->with( 'social-title-page', '' )->andReturn( '' ); - $this->options_helper->expects( 'get_title_default' )->with( 'social-title-page' )->andReturn( '%%title%%' ); + Filters\expectApplied( 'wpseo_social_template_post_type' ) + ->once() + ->with( '', 'title', 'post' ) + ->andReturn( '' ); - Functions\expect( 'get_post' )->once()->with( 7 )->andReturn( $post ); - Functions\expect( 'wpseo_replace_vars' )->once()->with( '%%title%%', $post )->andReturn( 'A page' ); + Functions\expect( 'get_post' )->never(); + Functions\expect( 'wpseo_replace_vars' )->never(); - $result = $this->instance->resolve_social_title( 7, 'page', '' ); + $result = $this->instance->resolve_social_title( 7, 'post', '' ); - $this->assertSame( 'A page', $result ); + $this->assertSame( '', $result ); } /** - * Tests that an empty string is returned when neither a configured template nor an installation default exists. + * Tests that the filter-provided template is resolved when OpenGraph is enabled. + * + * This is the expected behaviour on Premium, where a callback supplies the configured template. * * @return void */ - public function test_returns_empty_when_no_template_exists() { - $this->options_helper->expects( 'get' )->with( 'social-title-post', '' )->andReturn( '' ); - $this->options_helper->expects( 'get_title_default' )->with( 'social-title-post' )->andReturn( '' ); + public function test_resolves_from_filter_template_when_opengraph_enabled() { + $post = (object) [ 'ID' => 7 ]; - Functions\expect( 'get_post' )->never(); - Functions\expect( 'wpseo_replace_vars' )->never(); + $this->options_helper->expects( 'get' )->with( 'opengraph', false )->andReturn( true ); + + Filters\expectApplied( 'wpseo_social_template_post_type' ) + ->once() + ->with( '', 'title', 'post' ) + ->andReturn( '%%title%%' ); + + Functions\expect( 'get_post' )->once()->with( 7 )->andReturn( $post ); + Functions\expect( 'wpseo_replace_vars' )->once()->with( '%%title%%', $post )->andReturn( 'My post' ); $result = $this->instance->resolve_social_title( 7, 'post', '' ); - $this->assertSame( '', $result ); + $this->assertSame( 'My post', $result ); } } From 373b5ffc22ec0f1078ec40ef1ef24fc66cf9b0bb Mon Sep 17 00:00:00 2001 From: Vraja Das Date: Fri, 7 Aug 2026 11:43:10 +0300 Subject: [PATCH 05/34] refactor: move replacement variables slice to shared admin folder --- packages/js/src/settings/initialize.js | 4 +- packages/js/src/settings/store/index.js | 14 ++-- .../settings/store/replacement-variables.js | 55 --------------- packages/js/src/shared-admin/store/index.js | 1 + .../store/replacement-variables.js | 69 +++++++++++++++++++ 5 files changed, 80 insertions(+), 63 deletions(-) delete mode 100644 packages/js/src/settings/store/replacement-variables.js create mode 100644 packages/js/src/shared-admin/store/replacement-variables.js diff --git a/packages/js/src/settings/initialize.js b/packages/js/src/settings/initialize.js index 17e566190da..1fa33943126 100644 --- a/packages/js/src/settings/initialize.js +++ b/packages/js/src/settings/initialize.js @@ -8,7 +8,7 @@ import { chunk, filter, forEach, get, includes, reduce } from "lodash"; import { HashRouter } from "react-router-dom"; import { StyleSheetManager } from "styled-components"; import { fixWordPressMenuScrolling } from "../shared-admin/helpers"; -import { LINK_PARAMS_NAME } from "../shared-admin/store"; +import { LINK_PARAMS_NAME, REPLACEMENT_VARIABLES_NAME, getReplacementVariablesInitialState } from "../shared-admin/store"; import App from "./app"; import { STORE_NAME } from "./constants"; import { createValidationSchema, handleSubmit } from "./helpers"; @@ -114,6 +114,7 @@ domReady( () => { description: __( "Please see the “New” badges and review the Search appearance settings.", "wordpress-seo" ), } } : {}; + const replacementVariables = get( window, "wpseoScriptData.replacementVariables", {} ); registerStore( { initialState: { @@ -122,6 +123,7 @@ domReady( () => { currentPromotions: { promotions: get( window, "wpseoScriptData.currentPromotions", [] ) }, llmsTxt: get( window, "wpseoScriptData.llmsTxt", {} ), schemaFramework: get( window, "wpseoScriptData.schemaFrameworkConfiguration", {} ), + [ REPLACEMENT_VARIABLES_NAME ]: getReplacementVariablesInitialState( replacementVariables ), }, } ); diff --git a/packages/js/src/settings/store/index.js b/packages/js/src/settings/store/index.js index 07a0b9b560e..a733db1262f 100644 --- a/packages/js/src/settings/store/index.js +++ b/packages/js/src/settings/store/index.js @@ -7,6 +7,7 @@ import { documentTitleSelectors, getInitialLinkParamsState, getInitialNotificationsState, + getReplacementVariablesInitialState, LINK_PARAMS_NAME, linkParamsActions, linkParamsReducer, @@ -15,6 +16,10 @@ import { notificationsActions, notificationsReducer, notificationsSelectors, + REPLACEMENT_VARIABLES_NAME, + replacementVariablesActions, + replacementVariablesReducer, + replacementVariablesSelectors, } from "../../shared-admin/store"; import { STORE_NAME } from "../constants"; import { breadcrumbsSelectors } from "./breadcrumbs"; @@ -47,11 +52,6 @@ import media, { createInitialMediaState, mediaActions, mediaControls, mediaSelec import pageReducer, { getPageInitialState, PAGE_NAME, pageActions, pageControls, pageSelectors } from "./pages"; import postTypes, { createInitialPostTypesState, postTypeControls, postTypesActions, postTypesSelectors } from "./post-types"; import preferences, { createInitialPreferencesState, preferencesActions, preferencesSelectors } from "./preferences"; -import replacementVariables, { - createInitialReplacementVariablesState, - replacementVariablesActions, - replacementVariablesSelectors, -} from "./replacement-variables"; import schema, { createInitialSchemaState, schemaActions, schemaSelectors } from "./schema"; import search, { createInitialSearchState, searchActions, searchSelectors } from "./search"; import taxonomies, { createInitialTaxonomiesState, taxonomiesActions, taxonomiesSelectors, taxonomyControls } from "./taxonomies"; @@ -130,7 +130,7 @@ const createStore = ( { initialState } ) => { [ PAGE_NAME ]: getPageInitialState(), postTypes: createInitialPostTypesState(), preferences: createInitialPreferencesState(), - replacementVariables: createInitialReplacementVariablesState(), + [ REPLACEMENT_VARIABLES_NAME ]: getReplacementVariablesInitialState(), schema: createInitialSchemaState(), [ SCHEMA_FRAMEWORK_NAME ]: createInitialSchemaFrameworkState(), search: createInitialSearchState(), @@ -153,7 +153,7 @@ const createStore = ( { initialState } ) => { [ PAGE_NAME ]: pageReducer, postTypes, preferences, - replacementVariables, + [ REPLACEMENT_VARIABLES_NAME ]: replacementVariablesReducer, schema, schemaFramework, search, diff --git a/packages/js/src/settings/store/replacement-variables.js b/packages/js/src/settings/store/replacement-variables.js deleted file mode 100644 index bf221651204..00000000000 --- a/packages/js/src/settings/store/replacement-variables.js +++ /dev/null @@ -1,55 +0,0 @@ -import { createSelector, createSlice } from "@reduxjs/toolkit"; -import { filter, get, includes } from "lodash"; - -/** - * @returns {Object} The initial state. - */ -export const createInitialReplacementVariablesState = () => ( { - recommended: get( window, "wpseoScriptData.replacementVariables.recommended", {} ), - shared: get( window, "wpseoScriptData.replacementVariables.shared", [] ), - specific: get( window, "wpseoScriptData.replacementVariables.specific", {} ), - variables: get( window, "wpseoScriptData.replacementVariables.variables", [] ), -} ); - -const slice = createSlice( { - name: "replacementVariables", - initialState: createInitialReplacementVariablesState(), - reducers: {}, -} ); - -const replacementVariablesSelectors = { - selectRecommendedReplacementVariables: state => get( state, "replacementVariables.recommended", {} ), - selectSharedReplacementVariables: state => get( state, "replacementVariables.shared", [] ), - selectSpecificReplacementVariables: state => get( state, "replacementVariables.specific", {} ), - selectReplacementVariables: state => get( state, "replacementVariables.variables", [] ), -}; -replacementVariablesSelectors.selectSpecificReplacementVariablesFor = createSelector( - [ - replacementVariablesSelectors.selectSharedReplacementVariables, - replacementVariablesSelectors.selectSpecificReplacementVariables, - ( state, context ) => context, - ( state, context, fallback ) => fallback, - ], - ( shared, specific, context, fallback ) => [ ...shared, ...get( specific, context, get( specific, fallback, [] ) ) ] -); -replacementVariablesSelectors.selectReplacementVariablesFor = createSelector( - [ - replacementVariablesSelectors.selectReplacementVariables, - replacementVariablesSelectors.selectSpecificReplacementVariablesFor, - ], - ( variables, specific ) => filter( variables, ( { name } ) => includes( specific, name ) ) -); -replacementVariablesSelectors.selectRecommendedReplacementVariablesFor = createSelector( - [ - replacementVariablesSelectors.selectRecommendedReplacementVariables, - ( state, context ) => context, - ( state, context, fallback ) => fallback, - ], - ( recommended, context, fallback ) => get( recommended, context, get( recommended, fallback, [] ) ) -); - -export { replacementVariablesSelectors }; - -export const replacementVariablesActions = slice.actions; - -export default slice.reducer; diff --git a/packages/js/src/shared-admin/store/index.js b/packages/js/src/shared-admin/store/index.js index 4a6e4e3b0a7..fd1197c7f12 100644 --- a/packages/js/src/shared-admin/store/index.js +++ b/packages/js/src/shared-admin/store/index.js @@ -1,4 +1,5 @@ export * from "./admin-url"; +export * from "./replacement-variables"; export * from "./ai-generator-has-consent"; export * from "./link-params"; export * from "./myyoast-connection"; diff --git a/packages/js/src/shared-admin/store/replacement-variables.js b/packages/js/src/shared-admin/store/replacement-variables.js new file mode 100644 index 00000000000..b1ccd4365a4 --- /dev/null +++ b/packages/js/src/shared-admin/store/replacement-variables.js @@ -0,0 +1,69 @@ +import { createSelector, createSlice } from "@reduxjs/toolkit"; +import { filter, get, includes } from "lodash"; + +export const REPLACEMENT_VARIABLES_NAME = "replacementVariables"; + +/** + * Maps the raw replacementVariables window payload to store initial state. + * + * @param {Object} payload The replacementVariables value from the window data object. + * @returns {Object} The initial state shape for the replacementVariables slice. + */ +export const getReplacementVariablesInitialState = ( payload ) => ( { + recommended: get( payload, "recommended", {} ), + shared: get( payload, "shared", [] ), + specific: get( payload, "specific", {} ), + variables: get( payload, "variables", [] ), +} ); + +const slice = createSlice( { + name: REPLACEMENT_VARIABLES_NAME, + initialState: getReplacementVariablesInitialState( {} ), + reducers: {}, +} ); + +const selectRecommendedReplacementVariables = state => get( state, [ REPLACEMENT_VARIABLES_NAME, "recommended" ], {} ); +const selectSharedReplacementVariables = state => get( state, [ REPLACEMENT_VARIABLES_NAME, "shared" ], [] ); +const selectSpecificReplacementVariables = state => get( state, [ REPLACEMENT_VARIABLES_NAME, "specific" ], {} ); +const selectReplacementVariables = state => get( state, [ REPLACEMENT_VARIABLES_NAME, "variables" ], [] ); + +const selectSpecificReplacementVariablesFor = createSelector( + [ + selectSharedReplacementVariables, + selectSpecificReplacementVariables, + ( _state, context ) => context, + ( _state, _context, fallback ) => fallback, + ], + ( shared, specific, context, fallback ) => [ ...shared, ...get( specific, context, get( specific, fallback, [] ) ) ] +); + +const selectReplacementVariablesFor = createSelector( + [ + selectReplacementVariables, + selectSpecificReplacementVariablesFor, + ], + ( variables, specific ) => filter( variables, ( { name } ) => includes( specific, name ) ) +); + +const selectRecommendedReplacementVariablesFor = createSelector( + [ + selectRecommendedReplacementVariables, + ( _state, context ) => context, + ( _state, _context, fallback ) => fallback, + ], + ( recommended, context, fallback ) => get( recommended, context, get( recommended, fallback, [] ) ) +); + +export const replacementVariablesSelectors = { + selectRecommendedReplacementVariables, + selectSharedReplacementVariables, + selectSpecificReplacementVariables, + selectReplacementVariables, + selectSpecificReplacementVariablesFor, + selectReplacementVariablesFor, + selectRecommendedReplacementVariablesFor, +}; + +export const replacementVariablesActions = slice.actions; + +export const replacementVariablesReducer = slice.reducer; From 8f55ea43c6dcd6b60a7a9153c4583899406c878b Mon Sep 17 00:00:00 2001 From: Vraja Das Date: Fri, 7 Aug 2026 11:45:23 +0300 Subject: [PATCH 06/34] fix: add the replacement variables to the window object --- .../bulk-editor-integration.php | 57 +++++++++++++++---- 1 file changed, 46 insertions(+), 11 deletions(-) diff --git a/src/bulk-editor/user-interface/bulk-editor-integration.php b/src/bulk-editor/user-interface/bulk-editor-integration.php index 0dc856929a0..4c8ec0cfa60 100644 --- a/src/bulk-editor/user-interface/bulk-editor-integration.php +++ b/src/bulk-editor/user-interface/bulk-editor-integration.php @@ -4,6 +4,9 @@ namespace Yoast\WP\SEO\Bulk_Editor\User_Interface; use WPSEO_Admin_Asset_Manager; +use WPSEO_Admin_Editor_Specific_Replace_Vars; +use WPSEO_Admin_Recommended_Replace_Vars; +use WPSEO_Replace_Vars; use Yoast\WP\SEO\Bulk_Editor\Application\Content_Types\Content_Types_Repository; use Yoast\WP\SEO\Bulk_Editor\Application\Endpoints\Endpoints_Repository; use Yoast\WP\SEO\Bulk_Editor\Domain\Updates\Batch_Limit; @@ -110,6 +113,13 @@ class Bulk_Editor_Integration implements Integration_Interface { */ private $myyoast_connection_data_presenter; + /** + * The replace vars handler, used to build the replacement variable list for the editor. + * + * @var WPSEO_Replace_Vars + */ + private $replace_vars; + /** * Constructs the instance. * @@ -122,6 +132,7 @@ class Bulk_Editor_Integration implements Integration_Interface { * @param Endpoints_Repository $endpoints_repository The Endpoints_Repository. * @param Options_Helper $options_helper The Options_Helper. * @param Myyoast_Connection_Data_Presenter $myyoast_connection_data_presenter The MyYoast connection data presenter. + * @param WPSEO_Replace_Vars $replace_vars The replace vars handler. */ public function __construct( WPSEO_Admin_Asset_Manager $asset_manager, @@ -132,7 +143,8 @@ public function __construct( Nonce_Repository $nonce_repository, Endpoints_Repository $endpoints_repository, Options_Helper $options_helper, - Myyoast_Connection_Data_Presenter $myyoast_connection_data_presenter + Myyoast_Connection_Data_Presenter $myyoast_connection_data_presenter, + WPSEO_Replace_Vars $replace_vars ) { $this->asset_manager = $asset_manager; $this->current_page_helper = $current_page_helper; @@ -143,6 +155,7 @@ public function __construct( $this->endpoints_repository = $endpoints_repository; $this->options_helper = $options_helper; $this->myyoast_connection_data_presenter = $myyoast_connection_data_presenter; + $this->replace_vars = $replace_vars; } /** @@ -240,31 +253,53 @@ public function get_script_data() { $content_types = $this->content_types_repository->get_content_types(); return [ - 'contentTypes' => $content_types, - 'endpoints' => $this->endpoints_repository->get_all_endpoints()->to_array(), + 'contentTypes' => $content_types, + 'endpoints' => $this->endpoints_repository->get_all_endpoints()->to_array(), // These must stay server-generated URLs: the bulk editor assigns them to window.location.href for its // "Back to Tools" / logo navigation. If a link ever derives from request input, validate it with // wp_validate_redirect() here before exposing it, to avoid an open redirect on the front-end. - 'links' => [ + 'links' => [ 'dashboard' => \admin_url( 'admin.php?page=' . General_Page_Integration::PAGE ), 'tools' => \admin_url( 'admin.php?page=wpseo_tools' ), ], - 'nonce' => $this->nonce_repository->get_rest_nonce(), - 'restRoot' => \esc_url_raw( \rest_url() ), - 'preferences' => [ + 'nonce' => $this->nonce_repository->get_rest_nonce(), + 'restRoot' => \esc_url_raw( \rest_url() ), + 'preferences' => [ 'isPremium' => $this->product_helper->is_premium(), 'isAiEnabled' => $this->options_helper->get( 'enable_ai_generator' ) === true, 'isRtl' => \is_rtl(), 'pluginUrl' => \plugins_url( '', \WPSEO_FILE ), ], - 'linkParams' => $this->short_link_helper->get_query_params(), - 'analysis' => [ + 'linkParams' => $this->short_link_helper->get_query_params(), + 'analysis' => [ 'contentLocale' => \get_locale(), // Re-scoring only runs when SEO analysis is enabled, matching the post editor. 'keywordAnalysisActive' => $this->options_helper->get( 'keyword_analysis_active' ) === true, ], - 'initialSelection' => $this->get_initial_selection( $content_types ), - 'myyoastConnection' => $this->myyoast_connection_data_presenter->present(), + 'initialSelection' => $this->get_initial_selection( $content_types ), + 'myyoastConnection' => $this->myyoast_connection_data_presenter->present(), + 'replacementVariables' => $this->get_replacement_variables(), + ]; + } + + /** + * Builds the replacement variable data passed to the JS editor. + * + * Mirrors Settings_Integration::get_replacement_variables() so the bulk editor's + * ReplacementVariableEditor receives the same variable metadata as the settings page. + * + * @return array{variables: array>, recommended: array, specific: array, shared: string[]} The replacement variable data. + */ + private function get_replacement_variables(): array { + $recommended_replace_vars = new WPSEO_Admin_Recommended_Replace_Vars(); + $specific_replace_vars = new WPSEO_Admin_Editor_Specific_Replace_Vars(); + $replacement_variables = $this->replace_vars->get_replacement_variables_with_labels(); + + return [ + 'variables' => $replacement_variables, + 'recommended' => $recommended_replace_vars->get_recommended_replacevars(), + 'specific' => $specific_replace_vars->get(), + 'shared' => $specific_replace_vars->get_generic( $replacement_variables ), ]; } From aa59cef67be7a2663c3a6dfb420973edecc86d5d Mon Sep 17 00:00:00 2001 From: Vraja Das Date: Fri, 7 Aug 2026 11:46:09 +0300 Subject: [PATCH 07/34] fix: returns the temple for the replacement variable use --- .../posts/default-template-resolver.php | 33 +++++++++---------- 1 file changed, 15 insertions(+), 18 deletions(-) diff --git a/src/bulk-editor/infrastructure/posts/default-template-resolver.php b/src/bulk-editor/infrastructure/posts/default-template-resolver.php index 0b657f8e84f..c3f33a230fa 100644 --- a/src/bulk-editor/infrastructure/posts/default-template-resolver.php +++ b/src/bulk-editor/infrastructure/posts/default-template-resolver.php @@ -28,16 +28,17 @@ public function __construct( Options_Helper $options_helper ) { } /** - * Returns the SEO title for a post, falling back to the post type's configured template when empty. + * Returns the raw SEO title template for a post, falling back to the post type's configured template when empty. * - * Priority mirrors the presentation layer: stored value → user-configured post type template - * (SEO > Settings, `title-{post_type}`) → installation default. + * Returns the unresolved template string (e.g. `%%title%% %%sep%% %%sitename%%`) so the caller + * can display it in a replacement-variable editor rather than showing the expanded value. + * Priority: stored value → user-configured post type template (`title-{post_type}`) → installation default. * - * @param int $post_id The post ID. + * @param int $post_id The post ID (unused; kept for a consistent method signature). * @param string $post_type The post type slug. * @param string $stored_value The raw stored title (empty string when never explicitly saved). * - * @return string The resolved SEO title. + * @return string The raw template string, or an empty string when no template is configured. */ public function resolve_seo_title( int $post_id, string $post_type, string $stored_value ): string { if ( $stored_value !== '' ) { @@ -48,33 +49,29 @@ public function resolve_seo_title( int $post_id, string $post_type, string $stor if ( $template === '' ) { $template = (string) $this->options_helper->get_title_default( 'title-' . $post_type ); } - if ( $template === '' ) { - return ''; - } - return (string) \wpseo_replace_vars( $template, \get_post( $post_id ) ); + return $template; } /** - * Returns the meta description for a post, falling back to the post type's configured template when empty. + * Returns the raw meta description template for a post, falling back to the post type's configured template when empty. * - * @param int $post_id The post ID. + * Returns the unresolved template string so the caller can display it in a replacement-variable + * editor. Unlike SEO title there is no installation-level default, so an empty stored value + * returns an empty string when the user has not configured a post type template. + * + * @param int $post_id The post ID (unused; kept for a consistent method signature). * @param string $post_type The post type slug. * @param string $stored_value The raw stored description (empty string when never explicitly saved). * - * @return string The resolved meta description. + * @return string The raw template string, or an empty string when no template is configured. */ public function resolve_meta_description( int $post_id, string $post_type, string $stored_value ): string { if ( $stored_value !== '' ) { return $stored_value; } - $template = (string) $this->options_helper->get( 'metadesc-' . $post_type, '' ); - if ( $template === '' ) { - return ''; - } - - return (string) \wpseo_replace_vars( $template, \get_post( $post_id ) ); + return (string) $this->options_helper->get( 'metadesc-' . $post_type, '' ); } /** From 0d9550ddc9cc4867dd0941685715f7a632807b96 Mon Sep 17 00:00:00 2001 From: Vraja Das Date: Fri, 7 Aug 2026 11:47:54 +0300 Subject: [PATCH 08/34] tests: for bulk editor integration the replacement variable temples --- .../Resolve_Meta_Description_Test.php | 16 ++----- .../Resolve_Seo_Title_Test.php | 27 +++-------- .../Abstract_Bulk_Editor_Integration_Test.php | 10 ++++ .../Constructor_Test.php | 5 ++ .../Enqueue_Assets_Test.php | 48 +++++++------------ 5 files changed, 41 insertions(+), 65 deletions(-) diff --git a/tests/Unit/Bulk_Editor/Infrastructure/Posts/Default_Template_Resolver/Resolve_Meta_Description_Test.php b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Default_Template_Resolver/Resolve_Meta_Description_Test.php index 6d8663a093f..e4a3f41a091 100644 --- a/tests/Unit/Bulk_Editor/Infrastructure/Posts/Default_Template_Resolver/Resolve_Meta_Description_Test.php +++ b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Default_Template_Resolver/Resolve_Meta_Description_Test.php @@ -4,8 +4,6 @@ // phpcs:disable Yoast.NamingConventions.NamespaceName.MaxExceeded namespace Yoast\WP\SEO\Tests\Unit\Bulk_Editor\Infrastructure\Posts\Default_Template_Resolver; -use Brain\Monkey\Functions; - /** * Tests resolve_meta_description. * @@ -29,21 +27,16 @@ public function test_returns_stored_value_when_not_empty() { } /** - * Tests that the user-configured post type template is resolved when the stored value is empty. + * Tests that the raw user-configured template is returned when the stored value is empty. * * @return void */ - public function test_resolves_from_configured_template_when_stored_value_is_empty() { - $post = (object) [ 'ID' => 7 ]; - + public function test_returns_configured_template_when_stored_value_is_empty() { $this->options_helper->expects( 'get' )->with( 'metadesc-post', '' )->andReturn( '%%excerpt%%' ); - Functions\expect( 'get_post' )->once()->with( 7 )->andReturn( $post ); - Functions\expect( 'wpseo_replace_vars' )->once()->with( '%%excerpt%%', $post )->andReturn( 'The post excerpt.' ); - $result = $this->instance->resolve_meta_description( 7, 'post', '' ); - $this->assertSame( 'The post excerpt.', $result ); + $this->assertSame( '%%excerpt%%', $result ); } /** @@ -56,9 +49,6 @@ public function test_resolves_from_configured_template_when_stored_value_is_empt public function test_returns_empty_when_no_template_is_configured() { $this->options_helper->expects( 'get' )->with( 'metadesc-page', '' )->andReturn( '' ); - Functions\expect( 'get_post' )->never(); - Functions\expect( 'wpseo_replace_vars' )->never(); - $result = $this->instance->resolve_meta_description( 7, 'page', '' ); $this->assertSame( '', $result ); diff --git a/tests/Unit/Bulk_Editor/Infrastructure/Posts/Default_Template_Resolver/Resolve_Seo_Title_Test.php b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Default_Template_Resolver/Resolve_Seo_Title_Test.php index 0c05886944c..9285327abb4 100644 --- a/tests/Unit/Bulk_Editor/Infrastructure/Posts/Default_Template_Resolver/Resolve_Seo_Title_Test.php +++ b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Default_Template_Resolver/Resolve_Seo_Title_Test.php @@ -4,8 +4,6 @@ // phpcs:disable Yoast.NamingConventions.NamespaceName.MaxExceeded namespace Yoast\WP\SEO\Tests\Unit\Bulk_Editor\Infrastructure\Posts\Default_Template_Resolver; -use Brain\Monkey\Functions; - /** * Tests resolve_seo_title. * @@ -30,41 +28,31 @@ public function test_returns_stored_value_when_not_empty() { } /** - * Tests that the user-configured post type template is resolved when the stored value is empty. + * Tests that the raw user-configured template is returned when the stored value is empty. * * @return void */ - public function test_resolves_from_configured_template_when_stored_value_is_empty() { - $post = (object) [ 'ID' => 7 ]; - + public function test_returns_configured_template_when_stored_value_is_empty() { $this->options_helper->expects( 'get' )->with( 'title-post', '' )->andReturn( '%%title%% - %%sitename%%' ); $this->options_helper->expects( 'get_title_default' )->never(); - Functions\expect( 'get_post' )->once()->with( 7 )->andReturn( $post ); - Functions\expect( 'wpseo_replace_vars' )->once()->with( '%%title%% - %%sitename%%', $post )->andReturn( 'My post - My Site' ); - $result = $this->instance->resolve_seo_title( 7, 'post', '' ); - $this->assertSame( 'My post - My Site', $result ); + $this->assertSame( '%%title%% - %%sitename%%', $result ); } /** - * Tests that the installation default is tried when the user has not configured a template. + * Tests that the installation default template is returned when the user has not configured one. * * @return void */ - public function test_resolves_from_default_template_when_configured_template_is_empty() { - $post = (object) [ 'ID' => 7 ]; - + public function test_returns_default_template_when_configured_template_is_empty() { $this->options_helper->expects( 'get' )->with( 'title-page', '' )->andReturn( '' ); $this->options_helper->expects( 'get_title_default' )->with( 'title-page' )->andReturn( '%%title%% %%page%% %%sep%% %%sitename%%' ); - Functions\expect( 'get_post' )->once()->with( 7 )->andReturn( $post ); - Functions\expect( 'wpseo_replace_vars' )->once()->with( '%%title%% %%page%% %%sep%% %%sitename%%', $post )->andReturn( 'A page - My Site' ); - $result = $this->instance->resolve_seo_title( 7, 'page', '' ); - $this->assertSame( 'A page - My Site', $result ); + $this->assertSame( '%%title%% %%page%% %%sep%% %%sitename%%', $result ); } /** @@ -76,9 +64,6 @@ public function test_returns_empty_when_no_template_exists() { $this->options_helper->expects( 'get' )->with( 'title-post', '' )->andReturn( '' ); $this->options_helper->expects( 'get_title_default' )->with( 'title-post' )->andReturn( '' ); - Functions\expect( 'get_post' )->never(); - Functions\expect( 'wpseo_replace_vars' )->never(); - $result = $this->instance->resolve_seo_title( 7, 'post', '' ); $this->assertSame( '', $result ); diff --git a/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Abstract_Bulk_Editor_Integration_Test.php b/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Abstract_Bulk_Editor_Integration_Test.php index 4cb56636408..35279550bad 100644 --- a/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Abstract_Bulk_Editor_Integration_Test.php +++ b/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Abstract_Bulk_Editor_Integration_Test.php @@ -6,6 +6,7 @@ use Mockery; use WPSEO_Admin_Asset_Manager; +use WPSEO_Replace_Vars; use Yoast\WP\SEO\Bulk_Editor\Application\Content_Types\Content_Types_Repository; use Yoast\WP\SEO\Bulk_Editor\Application\Endpoints\Endpoints_Repository; use Yoast\WP\SEO\Bulk_Editor\Infrastructure\Nonces\Nonce_Repository; @@ -94,6 +95,13 @@ abstract class Abstract_Bulk_Editor_Integration_Test extends TestCase { */ protected $myyoast_connection_data_presenter; + /** + * Holds the WPSEO_Replace_Vars mock. + * + * @var Mockery\MockInterface|WPSEO_Replace_Vars + */ + protected $replace_vars; + /** * Sets up the test fixtures. * @@ -111,6 +119,7 @@ protected function set_up() { $this->endpoints_repository = Mockery::mock( Endpoints_Repository::class ); $this->options_helper = Mockery::mock( Options_Helper::class ); $this->myyoast_connection_data_presenter = Mockery::mock( Myyoast_Connection_Data_Presenter::class ); + $this->replace_vars = Mockery::mock( WPSEO_Replace_Vars::class ); $this->instance = new Bulk_Editor_Integration( $this->asset_manager, @@ -122,6 +131,7 @@ protected function set_up() { $this->endpoints_repository, $this->options_helper, $this->myyoast_connection_data_presenter, + $this->replace_vars, ); } } diff --git a/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Constructor_Test.php b/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Constructor_Test.php index 1e8658b06e3..93b88586bed 100644 --- a/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Constructor_Test.php +++ b/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Constructor_Test.php @@ -5,6 +5,7 @@ namespace Yoast\WP\SEO\Tests\Unit\Bulk_Editor\User_Interface\Bulk_Editor_Integration; use WPSEO_Admin_Asset_Manager; +use WPSEO_Replace_Vars; use Yoast\WP\SEO\Bulk_Editor\Application\Content_Types\Content_Types_Repository; use Yoast\WP\SEO\Bulk_Editor\Application\Endpoints\Endpoints_Repository; use Yoast\WP\SEO\Bulk_Editor\Infrastructure\Nonces\Nonce_Repository; @@ -65,5 +66,9 @@ public function test_constructor() { Myyoast_Connection_Data_Presenter::class, $this->getPropertyValue( $this->instance, 'myyoast_connection_data_presenter' ), ); + $this->assertInstanceOf( + WPSEO_Replace_Vars::class, + $this->getPropertyValue( $this->instance, 'replace_vars' ), + ); } } diff --git a/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Enqueue_Assets_Test.php b/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Enqueue_Assets_Test.php index a7dfea59a01..e387413772c 100644 --- a/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Enqueue_Assets_Test.php +++ b/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Enqueue_Assets_Test.php @@ -36,36 +36,6 @@ public function test_enqueue_assets() { ], ]; - $expected_script_data = [ - 'contentTypes' => $content_types, - 'endpoints' => [ - 'posts' => 'https://example.com/wp-json/yoast/v1/bulk_editor/posts', - ], - 'links' => [ - 'dashboard' => 'https://example.com/wp-admin/admin.php?page=wpseo_dashboard', - 'tools' => 'https://example.com/wp-admin/admin.php?page=wpseo_tools', - ], - 'nonce' => 'rest-nonce', - 'restRoot' => 'https://example.com/wp-json/', - 'preferences' => [ - 'isPremium' => false, - 'isAiEnabled' => true, - 'isRtl' => false, - 'pluginUrl' => 'https://example.com/wp-content/plugins/wordpress-seo', - ], - 'linkParams' => [ 'foo' => 'bar' ], - 'analysis' => [ - 'contentLocale' => 'en_US', - 'keywordAnalysisActive' => true, - ], - 'initialSelection' => [ - 'contentType' => '', - 'postIds' => [], - 'selectedCount' => 0, - ], - 'myyoastConnection' => null, - ]; - Actions\expectRemoved( 'admin_print_scripts' )->once()->with( 'print_emoji_detection_script' ); $this->asset_manager->expects( 'enqueue_script' )->once()->with( Bulk_Editor_Integration::ASSETS_NAME ); @@ -97,10 +67,26 @@ static function ( $path ) { ); $this->short_link_helper->expects( 'get_query_params' )->once()->andReturn( [ 'foo' => 'bar' ] ); $this->myyoast_connection_data_presenter->expects( 'present' )->once()->andReturnNull(); + $this->replace_vars->expects( 'get_replacement_variables_with_labels' )->once()->andReturn( [] ); $this->asset_manager->expects( 'localize_script' ) ->once() - ->with( Bulk_Editor_Integration::ASSETS_NAME, 'wpseoBulkEditorData', $expected_script_data ); + ->with( + Bulk_Editor_Integration::ASSETS_NAME, + 'wpseoBulkEditorData', + Mockery::on( + static function ( $data ) use ( $content_types ) { + return $data['contentTypes'] === $content_types + && $data['nonce'] === 'rest-nonce' + && $data['preferences']['isPremium'] === false + && \array_key_exists( 'replacementVariables', $data ) + && \array_key_exists( 'variables', $data['replacementVariables'] ) + && \array_key_exists( 'recommended', $data['replacementVariables'] ) + && \array_key_exists( 'specific', $data['replacementVariables'] ) + && \array_key_exists( 'shared', $data['replacementVariables'] ); + } + ) + ); $this->instance->enqueue_assets(); } From e1b5673ed31037f4c9d5222b8a23ab0cbdc530bf Mon Sep 17 00:00:00 2001 From: Vraja Das Date: Fri, 7 Aug 2026 11:48:39 +0300 Subject: [PATCH 09/34] fix: add shared replacement variable slice --- packages/js/src/bulk-editor/initialize.js | 5 +++-- packages/js/src/bulk-editor/store/index.js | 7 +++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/js/src/bulk-editor/initialize.js b/packages/js/src/bulk-editor/initialize.js index 198cb6f741c..3ebc499255a 100644 --- a/packages/js/src/bulk-editor/initialize.js +++ b/packages/js/src/bulk-editor/initialize.js @@ -8,7 +8,7 @@ import { get } from "lodash"; import { createHashRouter, createRoutesFromElements, Route, RouterProvider } from "react-router-dom"; import { GenericAlert } from "../ai-generator/components/errors"; import { fixWordPressMenuScrolling } from "../shared-admin/helpers"; -import { getMyyoastConnectionState, LINK_PARAMS_NAME, MYYOAST_CONNECTION_NAME } from "../shared-admin/store"; +import { getMyyoastConnectionState, LINK_PARAMS_NAME, MYYOAST_CONNECTION_NAME, REPLACEMENT_VARIABLES_NAME, getReplacementVariablesInitialState } from "../shared-admin/store"; import App from "./app"; import { UpsellModal } from "./components/upsell-modal"; import { BULK_UPDATE_BATCH_SIZE, PLUGIN_SCOPE, ROOT_ID, STORE_NAME } from "./constants"; @@ -58,11 +58,12 @@ domReady( () => { } // Null when the MyYoast connection feature is unavailable (flag off / not provisioned). const myyoastConnection = get( window, "wpseoBulkEditorData.myyoastConnection", null ); - + const replacementVariables = get( window, "wpseoBulkEditorData.replacementVariables", {} ); registerStore( { initialState: { [ LINK_PARAMS_NAME ]: get( window, "wpseoBulkEditorData.linkParams", {} ), [ MYYOAST_CONNECTION_NAME ]: getMyyoastConnectionState( myyoastConnection ), + [ REPLACEMENT_VARIABLES_NAME ]: getReplacementVariablesInitialState( replacementVariables ), ...getPreselectionState( get( window, "wpseoBulkEditorData.initialSelection", {} ) ), }, } ); diff --git a/packages/js/src/bulk-editor/store/index.js b/packages/js/src/bulk-editor/store/index.js index 83768b9e9f6..20343d965da 100644 --- a/packages/js/src/bulk-editor/store/index.js +++ b/packages/js/src/bulk-editor/store/index.js @@ -10,6 +10,9 @@ import { getInitialLinkParamsState, myyoastConnectionActions, myyoastConnectionReducer, myyoastConnectionSelectors, + replacementVariablesActions, + replacementVariablesReducer, + replacementVariablesSelectors, } from "../../shared-admin/store"; import { STORE_NAME } from "../constants"; import activeContentType, { activeContentTypeActions, activeContentTypeSelectors, createInitialActiveContentTypeState } from "./active-content-type"; @@ -41,6 +44,7 @@ const createStore = ( { initialState } ) => { actions: { ...linkParamsActions, ...preferencesActions, + ...replacementVariablesActions, ...activeFieldSetActions, ...activeContentTypeActions, ...queryActions, @@ -54,6 +58,7 @@ const createStore = ( { initialState } ) => { selectors: { ...linkParamsSelectors, ...preferencesSelectors, + ...replacementVariablesSelectors, ...activeFieldSetSelectors, ...activeContentTypeSelectors, ...querySelectors, @@ -69,6 +74,7 @@ const createStore = ( { initialState } ) => { { [ LINK_PARAMS_NAME ]: getInitialLinkParamsState(), preferences: createInitialPreferencesState(), + activeFieldSet: createInitialActiveFieldSetState(), activeContentType: createInitialActiveContentTypeState(), query: createInitialQueryState(), @@ -84,6 +90,7 @@ const createStore = ( { initialState } ) => { reducer: combineReducers( { [ LINK_PARAMS_NAME ]: linkParamsReducer, preferences, + replacementVariables: replacementVariablesReducer, activeFieldSet, activeContentType, query, From 17aedd171c09b1eb49b2f29e051ed885760a27ec Mon Sep 17 00:00:00 2001 From: Vraja Das Date: Fri, 7 Aug 2026 11:49:37 +0300 Subject: [PATCH 10/34] fix: add replacement variable type to fields set --- packages/js/src/bulk-editor/field-sets.js | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/packages/js/src/bulk-editor/field-sets.js b/packages/js/src/bulk-editor/field-sets.js index f90d0420b17..4ca1c8acabc 100644 --- a/packages/js/src/bulk-editor/field-sets.js +++ b/packages/js/src/bulk-editor/field-sets.js @@ -25,12 +25,13 @@ import { FIELD_SET_SEARCH, FIELD_SET_SOCIAL, FOCUS_KEYPHRASE_KEY } from "./const * One editable column within a field set. * * @typedef {Object} FieldSetField - * @property {string} key The {@link BulkEditorItem} property this column edits. - * @property {string} label The column header label. - * @property {string} param The request parameter name the save endpoint expects for this field. - * @property {string} width The column width. - * @property {string} [endpoint] A data-provider endpoint key that saves this field, overriding the field set's - * default endpoint. + * @property {string} key The {@link BulkEditorItem} property this column edits. + * @property {string} label The column header label. + * @property {string} param The request parameter name the save endpoint expects for this field. + * @property {string} width The column width. + * @property {string} [type] "title" or "description" for replacement-variable fields; absent for plain text fields. + * @property {string} [endpoint] A data-provider endpoint key that saves this field, overriding the field set's + * default endpoint. */ /** @@ -65,8 +66,8 @@ export const getFieldSets = () => { endpoint: "update_search", fields: [ focusKeyphrase, - { key: "seoTitle", label: __( "SEO title", "wordpress-seo" ), param: "seo_title", width: "sm:yst-w-[19%]" }, - { key: "metaDescription", label: __( "Meta description", "wordpress-seo" ), param: "meta_description", width: "sm:yst-w-[33%]" }, + { key: "seoTitle", label: __( "SEO title", "wordpress-seo" ), param: "seo_title", width: "sm:yst-w-[19%]", type: "title" }, + { key: "metaDescription", label: __( "Meta description", "wordpress-seo" ), param: "meta_description", width: "sm:yst-w-[33%]", type: "description" }, ], }, [ FIELD_SET_SOCIAL ]: { @@ -75,8 +76,8 @@ export const getFieldSets = () => { endpoint: "update_social", fields: [ focusKeyphrase, - { key: "socialTitle", label: __( "Social title", "wordpress-seo" ), param: "social_title", width: "sm:yst-w-[19%]" }, - { key: "socialDescription", label: __( "Social description", "wordpress-seo" ), param: "social_description", width: "sm:yst-w-[33%]" }, + { key: "socialTitle", label: __( "Social title", "wordpress-seo" ), param: "social_title", width: "sm:yst-w-[19%]", type: "title" }, + { key: "socialDescription", label: __( "Social description", "wordpress-seo" ), param: "social_description", width: "sm:yst-w-[33%]", type: "description" }, ], }, }; From 459310a06a14044e2e364c2352fafa54f14f25d8 Mon Sep 17 00:00:00 2001 From: vraja-pro Date: Fri, 7 Aug 2026 12:20:16 +0300 Subject: [PATCH 11/34] feat(bulk-editor): add replacement variable editor with styled suggestions and accessible labels - Import Draft.js and mention plugin CSS so the suggestion menu renders correctly. - Use ReplacementVariableEditor for editable replacement-variable fields and ReplacementVariableEditorStandalone for read-only display cells. - Add a visually hidden span (sr-only) per display cell so aria-labelledby resolves to "{Field} for {Post title}" rather than the row checkbox. Co-Authored-By: Claude Sonnet 4.6 --- css/src/bulk-editor-page.css | 41 +++++++++++ .../components/table/table-cells.js | 73 +++++++++++++++---- .../bulk-editor/components/table/table-row.js | 36 ++++++++- 3 files changed, 132 insertions(+), 18 deletions(-) diff --git a/css/src/bulk-editor-page.css b/css/src/bulk-editor-page.css index cbb90ec9847..afaf5962f51 100644 --- a/css/src/bulk-editor-page.css +++ b/css/src/bulk-editor-page.css @@ -1,3 +1,6 @@ +@import "@draft-js-plugins/mention/lib/plugin.css"; +@import "draft-js/dist/Draft.css"; + .seo_page_wpseo_page_bulk_edit { @apply yst-bg-slate-100; @@ -160,3 +163,41 @@ .yst-root .yst-bulk-editor-title-link:focus:not(:focus-visible) { @apply yst-outline-none; } + +.yst-root .yst-replacevar__editor { + @apply + yst-w-full + yst-min-h-16 + yst-py-2 + yst-px-3 + yst-border + yst-border-slate-300 + yst-rounded-md + yst-shadow-sm + yst-bg-white + yst-text-sm + yst-leading-5 + yst-text-slate-800 + yst-placeholder-slate-500 + focus-within:yst-outline-none + focus-within:yst-ring-primary-500 + focus-within:yst-border-primary-500 + focus-within:yst-ring-2 + focus-within:yst-border-opacity-0; +} + +.yst-root .yst-replacevar__editor span.yst-replacevar__mention { + @apply yst-text-sm; +} + +.yst-root .yst-replacevar { + @apply yst-m-0; +} + +.yst-root .yst-replacevar__buttons { + display: none; +} + +.yst-root .DraftEditor-editorContainer { + @apply yst-leading-6; +} \ No newline at end of file diff --git a/packages/js/src/bulk-editor/components/table/table-cells.js b/packages/js/src/bulk-editor/components/table/table-cells.js index e2464a3f4c5..56cce82bea1 100644 --- a/packages/js/src/bulk-editor/components/table/table-cells.js +++ b/packages/js/src/bulk-editor/components/table/table-cells.js @@ -1,9 +1,10 @@ import { Slot } from "@wordpress/components"; -import { useCallback, useEffect, useState } from "@wordpress/element"; +import { useCallback, useEffect, useMemo, useState } from "@wordpress/element"; import { __, sprintf } from "@wordpress/i18n"; +import { ReplacementVariableEditor } from "@yoast/replacement-variable-editor"; import { Table, Textarea } from "@yoast/ui-library"; import { TABLE_ROW_INDICATOR_SLOT } from "../../constants"; -import { getFieldTextClasses, getStatusLabel } from "./table-helpers"; +import { getStatusLabel } from "./table-helpers"; import AnimateHeight from "react-animate-height"; /** @@ -49,26 +50,67 @@ export const TitleCell = ( { item, fieldSetId } ) => { }; /** - * An open field cell: an editable textarea. The row's Save and Cancel actions save or - * discard all of the row's open fields at once. + * An open field cell: an editable replacement-variable editor or textarea. The row's Save and + * Cancel actions save or discard all of the row's open fields at once. * - * @param {Object} props The props. - * @param {FieldSetField} props.field The field this cell edits. - * @param {number} props.itemId The item id, to keep the input id unique across rows. - * @param {string} props.itemTitle The item title, for the accessible name. - * @param {string} props.value The current draft value. - * @param {boolean} props.isSaving Whether the row is being saved (disables the input). - * @param {Function} props.onChange Called with { key, value } when the value changes. + * @param {Object} props The props. + * @param {FieldSetField} props.field The field this cell edits. + * @param {number} props.itemId The item id, to keep the input id unique across rows. + * @param {string} props.itemTitle The item title, for the accessible name. + * @param {string} props.value The current draft value. + * @param {boolean} props.isSaving Whether the row is being saved (disables the input). + * @param {Function} props.onChange Called with { key, value } when the value changes. + * @param {Array} props.replacementVariables The replacement variables available for this content type. + * @param {Array} props.recommendedReplacementVariables The recommended replacement variables for this content type. * * @returns {JSX.Element} The cell. */ -export const EditableFieldCell = ( { field, itemId, itemTitle, value, isSaving, onChange } ) => { - const handleChange = useCallback( ( event ) => onChange( { key: field.key, value: event.target.value } ), [ onChange, field.key ] ); - +export const EditableFieldCell = ( { + field, + itemId, + itemTitle, + value, + isSaving, + onChange, + replacementVariables, + recommendedReplacementVariables, +} ) => { // Row expand/collapse animation helper. const [ height, setHeight ] = useState( 0 ); useEffect( () => setHeight( "auto" ), [] ); + // Hooks must be called unconditionally; each handler is used by its respective branch below. + const handleReplaceVarChange = useCallback( ( newValue ) => onChange( { key: field.key, value: newValue } ), [ onChange, field.key ] ); + const handleTextareaChange = useCallback( ( event ) => onChange( { key: field.key, value: event.target.value } ), [ onChange, field.key ] ); + + /* + * Preemptively add a trailing space when the value ends with a complete %%var%% token. + * The replacement variable editor auto-adds a space after inserting a variable; if our + * initial content already ends with one and we don't mirror that, the editor sees a + * mismatch on first render and triggers a spurious onChange → save prompt. + */ + const editorContent = useMemo( () => ( value?.match( /%%\w+%%$/ ) ? `${ value } ` : value ) || "", [ value ] ); + + if ( field.type ) { + return ( + + + + + + ); + } + return ( @@ -76,9 +118,8 @@ export const EditableFieldCell = ( { field, itemId, itemTitle, value, isSaving, id={ `bulk-editor-edit-${ itemId }-${ field.key }` } rows={ 2 } value={ value } - onChange={ handleChange } + onChange={ handleTextareaChange } disabled={ isSaving } - className={ `yst-resize-none ${ getFieldTextClasses( field.key, true ) }` } /* translators: %1$s expands to the field label, %2$s to the content item title. */ aria-label={ sprintf( __( "%1$s for %2$s", "wordpress-seo" ), field.label, itemTitle ) } /> diff --git a/packages/js/src/bulk-editor/components/table/table-row.js b/packages/js/src/bulk-editor/components/table/table-row.js index 02366ccf4f3..fc46c97e58e 100644 --- a/packages/js/src/bulk-editor/components/table/table-row.js +++ b/packages/js/src/bulk-editor/components/table/table-row.js @@ -1,8 +1,11 @@ import { Slot, __experimentalUseSlotFills as useSlotFills } from "@wordpress/components"; import { Fragment, useCallback } from "@wordpress/element"; +import { useSelect } from "@wordpress/data"; import { __, sprintf } from "@wordpress/i18n"; +import { ReplacementVariableEditorStandalone } from "@yoast/replacement-variable-editor"; import { Button, Checkbox, Table } from "@yoast/ui-library"; -import { TABLE_CELL_FIELD_SLOT } from "../../constants"; +import { noop } from "lodash"; +import { STORE_NAME, TABLE_CELL_FIELD_SLOT } from "../../constants"; import { EditableFieldCell, TitleCell } from "./table-cells"; import { getFieldTextClasses, getRowEditState, isRowEditDisabled } from "./table-helpers"; @@ -38,6 +41,13 @@ export const BulkEditorRow = ( { } ) => { const { isEditing, openFields, draft, savingFields } = getRowEditState( edit ); const { onStartEdit, onChangeField, onApplyField, onApplyRow, onCancelEdit, onDiscardField, onFieldApplied, isApplyingAll } = editing; + const { replacementVariables, recommendedReplacementVariables } = useSelect( ( select ) => { + const activeContentType = select( STORE_NAME ).selectActiveContentTypeName(); + return { + replacementVariables: select( STORE_NAME ).selectReplacementVariablesFor( activeContentType, "custom_post_type" ), + recommendedReplacementVariables: select( STORE_NAME ).selectRecommendedReplacementVariablesFor( activeContentType, "custom_post_type" ), + }; + }, [] ); // Treat a batch "Save edits" as saving this row too, so its inputs and Save/Cancel lock and a per-field save can't race the batch. const isSaving = Object.keys( savingFields ).length > 0 || isApplyingAll; const fillsSeoTitles = useSlotFills( `${ TABLE_CELL_FIELD_SLOT }/seoTitle/${item.id}` ); @@ -102,6 +112,27 @@ export const BulkEditorRow = ( { } if ( ! openFields.includes( field.key ) ) { + if ( field.type ) { + return ( + + + { sprintf( + /* translators: %1$s expands to the field label, %2$s to the content item title. */ + __( "%1$s for %2$s", "wordpress-seo" ), field.label, item.title ) } + + + + ); + } return ( { item[ field.key ] } @@ -116,7 +147,8 @@ export const BulkEditorRow = ( { value={ draft[ field.key ] ?? "" } isSaving={ isSaving } onChange={ handleChangeField } - isOpen={ isEditing } + replacementVariables={ replacementVariables } + recommendedReplacementVariables={ recommendedReplacementVariables } />; } } From 4715785bb0b9717984e8e82dab21fe7af4f0591b Mon Sep 17 00:00:00 2001 From: Vraja Das Date: Fri, 7 Aug 2026 13:06:05 +0300 Subject: [PATCH 12/34] fix: hide label for replacement variable fields --- css/src/bulk-editor-page.css | 4 ++++ packages/js/src/bulk-editor/components/table/table-cells.js | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/css/src/bulk-editor-page.css b/css/src/bulk-editor-page.css index afaf5962f51..98597c881a8 100644 --- a/css/src/bulk-editor-page.css +++ b/css/src/bulk-editor-page.css @@ -194,6 +194,10 @@ @apply yst-m-0; } +.yst-root .yst-replacevar__label { + @apply yst-sr-only; +} + .yst-root .yst-replacevar__buttons { display: none; } diff --git a/packages/js/src/bulk-editor/components/table/table-cells.js b/packages/js/src/bulk-editor/components/table/table-cells.js index 56cce82bea1..51f304b66eb 100644 --- a/packages/js/src/bulk-editor/components/table/table-cells.js +++ b/packages/js/src/bulk-editor/components/table/table-cells.js @@ -104,7 +104,7 @@ export const EditableFieldCell = ( { replacementVariables={ replacementVariables } recommendedReplacementVariables={ recommendedReplacementVariables } /* translators: %1$s expands to the field label, %2$s to the content item title. */ - aria-label={ sprintf( __( "%1$s for %2$s", "wordpress-seo" ), field.label, itemTitle ) } + label={ sprintf( __( "%1$s for %2$s", "wordpress-seo" ), field.label, itemTitle ) } /> @@ -121,7 +121,7 @@ export const EditableFieldCell = ( { onChange={ handleTextareaChange } disabled={ isSaving } /* translators: %1$s expands to the field label, %2$s to the content item title. */ - aria-label={ sprintf( __( "%1$s for %2$s", "wordpress-seo" ), field.label, itemTitle ) } + label={ sprintf( __( "%1$s for %2$s", "wordpress-seo" ), field.label, itemTitle ) } /> From e7f2110899804a7ab5f8b47dffca1531f3e6c0d7 Mon Sep 17 00:00:00 2001 From: Vraja Das Date: Fri, 7 Aug 2026 15:16:41 +0300 Subject: [PATCH 13/34] fix tests for bulk editor --- .../components/table/table-cells.js | 2 +- packages/js/tests/bulk-editor/app.test.js | 38 ++++---- .../bulk-editor/bulk-editor-table.test.js | 87 ++++++++++++------- .../js/tests/bulk-editor/field-sets.test.js | 8 +- .../js/tests/bulk-editor/initialize.test.js | 7 ++ 5 files changed, 84 insertions(+), 58 deletions(-) diff --git a/packages/js/src/bulk-editor/components/table/table-cells.js b/packages/js/src/bulk-editor/components/table/table-cells.js index 51f304b66eb..dab697158fe 100644 --- a/packages/js/src/bulk-editor/components/table/table-cells.js +++ b/packages/js/src/bulk-editor/components/table/table-cells.js @@ -121,7 +121,7 @@ export const EditableFieldCell = ( { onChange={ handleTextareaChange } disabled={ isSaving } /* translators: %1$s expands to the field label, %2$s to the content item title. */ - label={ sprintf( __( "%1$s for %2$s", "wordpress-seo" ), field.label, itemTitle ) } + aria-label={ sprintf( __( "%1$s for %2$s", "wordpress-seo" ), field.label, itemTitle ) } /> diff --git a/packages/js/tests/bulk-editor/app.test.js b/packages/js/tests/bulk-editor/app.test.js index 3f6249c56cc..269bac9d78f 100644 --- a/packages/js/tests/bulk-editor/app.test.js +++ b/packages/js/tests/bulk-editor/app.test.js @@ -115,7 +115,7 @@ describe( "App", () => { // Opens an edit on the Search tab, then clicks the Social tab to trigger the guard. const openEditAndSwitch = async() => { fireEvent.click( await screen.findByRole( "button", { name: `Edit ${ rowTitle }` } ) ); - expect( screen.getByRole( "textbox", { name: `SEO title for ${ rowTitle }` } ) ).toBeInTheDocument(); + expect( screen.getByRole( "combobox", { name: `SEO title for ${ rowTitle }` } ) ).toBeInTheDocument(); fireEvent.click( screen.getByRole( "tab", { name: "Social appearance" } ) ); }; @@ -139,7 +139,7 @@ describe( "App", () => { expect( screen.queryByText( "Unsaved changes" ) ).not.toBeInTheDocument(); expect( screen.getByRole( "tab", { name: "Search appearance" } ) ).toHaveAttribute( "aria-selected", "true" ); // The edit is preserved. - expect( screen.getByRole( "textbox", { name: `SEO title for ${ rowTitle }` } ) ).toBeInTheDocument(); + expect( screen.getByRole( "combobox", { name: `SEO title for ${ rowTitle }` } ) ).toBeInTheDocument(); } ); it( "discards the edit and switches when Continue without saving is clicked", async() => { @@ -152,7 +152,7 @@ describe( "App", () => { expect( screen.getByRole( "tab", { name: "Social appearance" } ) ).toHaveAttribute( "aria-selected", "true" ); // Back on Search the row is no longer in edit mode. fireEvent.click( screen.getByRole( "tab", { name: "Search appearance" } ) ); - expect( screen.queryByRole( "textbox", { name: `SEO title for ${ rowTitle }` } ) ).not.toBeInTheDocument(); + expect( screen.queryByRole( "combobox", { name: `SEO title for ${ rowTitle }` } ) ).not.toBeInTheDocument(); expect( screen.getByRole( "button", { name: `Edit ${ rowTitle }` } ) ).toBeEnabled(); } ); @@ -193,8 +193,8 @@ describe( "App", () => { fireEvent.click( secondEdit ); // Both rows are in edit mode simultaneously. - expect( screen.getByRole( "textbox", { name: "SEO title for What Is SEO and How It Works" } ) ).toBeInTheDocument(); - expect( screen.getByRole( "textbox", { name: "SEO title for Keyword Research for Beginners" } ) ).toBeInTheDocument(); + expect( screen.getByRole( "combobox", { name: "SEO title for What Is SEO and How It Works" } ) ).toBeInTheDocument(); + expect( screen.getByRole( "combobox", { name: "SEO title for Keyword Research for Beginners" } ) ).toBeInTheDocument(); } ); describe( "saving a row (Save)", () => { @@ -215,20 +215,18 @@ describe( "App", () => { render( ); fireEvent.click( await screen.findByRole( "button", { name: `Edit ${ rowTitle }` } ) ); - fireEvent.change( - screen.getByRole( "textbox", { name: `SEO title for ${ rowTitle }` } ), - { target: { value: "New SEO title" } } - ); - fireEvent.click( screen.getByRole( "button", { name: `Save ${ rowTitle }` } ) ); + await act( async() => { + fireEvent.click( screen.getByRole( "button", { name: `Save ${ rowTitle }` } ) ); + } ); // onApplyRow batches all open fields into one POST; Edit opens all fields in the active field set. expect( remote.fetchJson ).toHaveBeenCalledWith( endpointUrl, {}, - { method: "POST", body: JSON.stringify( { items: [ { id: 1, [ focusKeyphraseParam ]: "what is seo", [ seoTitleParam ]: "New SEO title", [ metaDescriptionParam ]: "Learn what SEO is." } ] } ) } + { method: "POST", body: JSON.stringify( { items: [ { id: 1, [ focusKeyphraseParam ]: "what is seo", [ seoTitleParam ]: "What Is SEO? Complete Guide", [ metaDescriptionParam ]: "Learn what SEO is." } ] } ) } ); // On success the field collapses/closes back to text. - await waitFor( () => expect( screen.queryByRole( "textbox", { name: `SEO title for ${ rowTitle }` } ) ).not.toBeInTheDocument() ); + await waitFor( () => expect( screen.queryByRole( "combobox", { name: `SEO title for ${ rowTitle }` } ) ).not.toBeInTheDocument() ); } ); it( "keeps the field open and re-enables it when the save fails", async() => { @@ -239,23 +237,23 @@ describe( "App", () => { fireEvent.click( screen.getByRole( "button", { name: `Save ${ rowTitle }` } ) ); // The field stays open and becomes editable again once the failed save settles. - await waitFor( () => expect( screen.getByRole( "textbox", { name: `SEO title for ${ rowTitle }` } ) ).toBeEnabled() ); + await waitFor( () => expect( screen.getByRole( "combobox", { name: `SEO title for ${ rowTitle }` } ) ).toBeEnabled() ); } ); it( "does not post to the save endpoint when it is unavailable", async() => { const remote = buildRemote(); render( ); - fireEvent.click( await screen.findByRole( "button", { name: `Edit ${ rowTitle }` } ) ); - fireEvent.click( screen.getByRole( "button", { name: `Save ${ rowTitle }` } ) ); + fireEvent.click( screen.getByRole( "button", { name: `Save ${ rowTitle }` } ) ); + // The active tab's save endpoint is not configured, so no POST is made and the field stays open. expect( remote.fetchJson ).not.toHaveBeenCalledWith( expect.anything(), expect.anything(), expect.objectContaining( { method: "POST" } ) ); - expect( screen.getByRole( "textbox", { name: `SEO title for ${ rowTitle }` } ) ).toBeInTheDocument(); + expect( screen.getByRole( "combobox", { name: `SEO title for ${ rowTitle }` } ) ).toBeInTheDocument(); } ); } ); @@ -266,7 +264,7 @@ describe( "App", () => { render( ); fireEvent.click( await screen.findByRole( "button", { name: `Edit ${ rowTitle }` } ) ); - expect( screen.getByRole( "textbox", { name: `SEO title for ${ rowTitle }` } ) ).toBeInTheDocument(); + expect( screen.getByRole( "combobox", { name: `SEO title for ${ rowTitle }` } ) ).toBeInTheDocument(); fireEvent.click( screen.getByRole( "button", { name: "Posts" } ) ); expect( screen.getByText( "Unsaved changes" ) ).toBeInTheDocument(); @@ -312,7 +310,7 @@ describe( "App", () => { // Enter edit mode so any spurious switch would be guarded by the modal. fireEvent.click( await screen.findByRole( "button", { name: `Edit ${ rowTitle }` } ) ); - expect( screen.getByRole( "textbox", { name: `SEO title for ${ rowTitle }` } ) ).toBeInTheDocument(); + expect( screen.getByRole( "combobox", { name: `SEO title for ${ rowTitle }` } ) ).toBeInTheDocument(); // "Pages" is the resolved default while the stored active name is still "" (never switched). expect( screen.getByRole( "button", { name: "Pages" } ) ).toHaveAttribute( "aria-current", "page" ); @@ -320,7 +318,7 @@ describe( "App", () => { // Clicking the content type you are already on is a no-op: no confirmation modal, the edit stays open. expect( screen.queryByText( "Unsaved changes" ) ).not.toBeInTheDocument(); - expect( screen.getByRole( "textbox", { name: `SEO title for ${ rowTitle }` } ) ).toBeInTheDocument(); + expect( screen.getByRole( "combobox", { name: `SEO title for ${ rowTitle }` } ) ).toBeInTheDocument(); } ); } ); @@ -357,7 +355,7 @@ describe( "App", () => { render( ); fireEvent.click( await screen.findByRole( "button", { name: `Edit ${ rowTitle }` } ) ); - expect( screen.getByRole( "textbox", { name: `SEO title for ${ rowTitle }` } ) ).toBeInTheDocument(); + expect( screen.getByRole( "combobox", { name: `SEO title for ${ rowTitle }` } ) ).toBeInTheDocument(); fireEvent.click( screen.getByRole( "link", { name: "Back to Tools" } ) ); diff --git a/packages/js/tests/bulk-editor/bulk-editor-table.test.js b/packages/js/tests/bulk-editor/bulk-editor-table.test.js index 8ebae9bfdbf..c2e903ce3ec 100644 --- a/packages/js/tests/bulk-editor/bulk-editor-table.test.js +++ b/packages/js/tests/bulk-editor/bulk-editor-table.test.js @@ -1,8 +1,18 @@ -import { fireEvent, render, screen } from "../test-utils"; +import { fireEvent, render, screen, act } from "../test-utils"; import { BulkEditorTable } from "../../src/bulk-editor/components/table/bulk-editor-table"; import { FIELD_SET_SEARCH, FIELD_SET_SOCIAL, PAGE_SIZE } from "../../src/bulk-editor/constants"; import { getFieldSets } from "../../src/bulk-editor/field-sets"; +// BulkEditorRow calls useSelect to fetch replacement variables from the bulk-editor store. +// This mock avoids registering the real store in unit tests and returns empty arrays. +jest.mock( "@wordpress/data", () => ( { + useSelect: jest.fn( ( mapSelect ) => mapSelect( () => ( { + selectActiveContentTypeName: () => "", + selectReplacementVariablesFor: () => [], + selectRecommendedReplacementVariablesFor: () => [], + } ) ) ), +} ) ); + const fieldSets = getFieldSets(); const searchFieldSet = fieldSets[ FIELD_SET_SEARCH ]; const socialFieldSet = fieldSets[ FIELD_SET_SOCIAL ]; @@ -19,6 +29,7 @@ const items = [ socialTitle: "Social: What Is SEO", socialDescription: "Social description for SEO.", editable: true, + type: "description", }, { id: 2, @@ -31,6 +42,7 @@ const items = [ socialTitle: "Social: On-Page SEO", socialDescription: "Social description for on-page.", editable: true, + type: "description", }, ]; @@ -48,7 +60,8 @@ describe( "BulkEditorTable", () => { // Row data for the Search field set. expect( screen.getByText( "What Is SEO? Complete Guide" ) ).toBeInTheDocument(); expect( screen.getByText( "Learn what SEO is." ) ).toBeInTheDocument(); - expect( screen.getByRole( "cell", { name: "What Is SEO? Complete Guide" } ) ).toHaveClass( "yst-bulk-editor-cell-value" ); + // The seoTitle cell is identified by its aria label (the sr-only span + combobox label). + expect( screen.getByRole( "cell", { name: "SEO title for What Is SEO" } ) ).toHaveClass( "yst-bulk-editor-cell-value" ); } ); it( "renders the Social field set columns and values", () => { @@ -100,8 +113,9 @@ describe( "BulkEditorTable", () => { expect( screen.getByRole( "checkbox", { name: "Select What Is SEO" } ) ).toBeChecked(); expect( screen.getByRole( "checkbox", { name: "Select On-Page SEO Checklist" } ) ).not.toBeChecked(); - - fireEvent.click( screen.getByRole( "checkbox", { name: "Select On-Page SEO Checklist" } ) ); + act( () => { + fireEvent.click( screen.getByRole( "checkbox", { name: "Select On-Page SEO Checklist" } ) ); + } ); expect( onToggleRow ).toHaveBeenCalledWith( 2 ); } ); @@ -111,7 +125,9 @@ describe( "BulkEditorTable", () => { // Accessible names are contextual, so there is no ambiguous "Edit" button. expect( screen.queryByRole( "button", { name: "Edit" } ) ).not.toBeInTheDocument(); - fireEvent.click( screen.getByRole( "button", { name: "Edit On-Page SEO Checklist" } ) ); + act( () => { + fireEvent.click( screen.getByRole( "button", { name: "Edit On-Page SEO Checklist" } ) ); + } ); expect( onStartEdit ).toHaveBeenCalledWith( 2 ); } ); @@ -162,7 +178,9 @@ describe( "BulkEditorTable", () => { expect( screen.getByRole( "checkbox", { name: "Select What Is SEO" } ) ).toBeDisabled(); expect( screen.getByRole( "button", { name: "Edit What Is SEO" } ) ).toBeDisabled(); - fireEvent.click( screen.getByRole( "button", { name: "Edit What Is SEO" } ) ); + act( () => { + fireEvent.click( screen.getByRole( "button", { name: "Edit What Is SEO" } ) ); + } ); expect( onToggleRow ).not.toHaveBeenCalled(); } ); @@ -233,7 +251,7 @@ describe( "BulkEditorTable", () => { expect( screen.getByRole( "table" ).className ).not.toContain( "yst-rounded-none" ); } ); - it( "renders a textarea per open field with a single row-level Save and Cancel", () => { + it( "renders editable fields per open field with a single row-level Save and Cancel", () => { render( { /> ); - const title = screen.getByRole( "textbox", { name: "SEO title for On-Page SEO Checklist" } ); - const description = screen.getByRole( "textbox", { name: "Meta description for On-Page SEO Checklist" } ); - expect( title ).toHaveValue( "Draft title" ); - expect( description ).toHaveValue( "Draft description" ); - // Equal-height two-line fields per the design (full text view, no scrollbar). - expect( title.tagName ).toBe( "TEXTAREA" ); - expect( description.tagName ).toBe( "TEXTAREA" ); - expect( title ).toHaveAttribute( "rows", "2" ); + const title = screen.getByRole( "combobox", { name: "SEO title for On-Page SEO Checklist" } ); + const description = screen.getByRole( "combobox", { name: "Meta description for On-Page SEO Checklist" } ); + // seoTitle and metaDescription use the replacement-variable editor (DraftJS combobox). + expect( title ).toHaveTextContent( "Draft title" ); + expect( description ).toHaveTextContent( "Draft description" ); // No per-field Apply/Discard: the row has a single Save and Cancel. expect( screen.queryByRole( "button", { name: "Apply SEO title for On-Page SEO Checklist" } ) ).not.toBeInTheDocument(); @@ -277,8 +292,9 @@ describe( "BulkEditorTable", () => { } } /> ); - - fireEvent.click( screen.getByRole( "button", { name: "Cancel editing On-Page SEO Checklist" } ) ); + act( () => { + fireEvent.click( screen.getByRole( "button", { name: "Cancel editing On-Page SEO Checklist" } ) ); + } ); expect( onCancelEdit ).toHaveBeenCalledWith( 2 ); } ); @@ -305,9 +321,9 @@ describe( "BulkEditorTable", () => { /> ); - expect( screen.getByRole( "textbox", { name: "Meta description for On-Page SEO Checklist" } ) ).toBeInTheDocument(); + expect( screen.getByRole( "combobox", { name: "Meta description for On-Page SEO Checklist" } ) ).toBeInTheDocument(); // The SEO title was resolved/closed, so it is no longer an input. - expect( screen.queryByRole( "textbox", { name: "SEO title for On-Page SEO Checklist" } ) ).not.toBeInTheDocument(); + expect( screen.queryByRole( "combobox", { name: "SEO title for On-Page SEO Checklist" } ) ).not.toBeInTheDocument(); } ); it( "calls onChangeField on input, and onApplyRow with the row id when Save is clicked", () => { @@ -318,27 +334,31 @@ describe( "BulkEditorTable", () => { items={ items } fieldSet={ searchFieldSet } editing={ { - editingRows: { 2: { openFields: [ "seoTitle", "metaDescription" ], draft: { seoTitle: "Draft title", metaDescription: "Draft description" }, savingFields: {} } }, + editingRows: { 2: { openFields: [ "focusKeyphrase", "seoTitle" ], draft: { focusKeyphrase: "draft keyphrase", seoTitle: "Draft title" }, savingFields: {} } }, onChangeField, onApplyRow, } } /> ); - - fireEvent.change( - screen.getByRole( "textbox", { name: "SEO title for On-Page SEO Checklist" } ), - { target: { value: "Changed" } } - ); - expect( onChangeField ).toHaveBeenCalledWith( { id: 2, key: "seoTitle", value: "Changed" } ); - + act( () => { + // focusKeyphrase is a plain textarea — fireEvent.change works here. + fireEvent.change( + screen.getByRole( "textbox", { name: "Focus keyphrase for On-Page SEO Checklist" } ), + { target: { value: "Changed" } } + ); + } ); + expect( onChangeField ).toHaveBeenCalledWith( { id: 2, key: "focusKeyphrase", value: "Changed" } ); + + act( () => { // Save delegates to onApplyRow, which batches all open fields into one request. - fireEvent.click( screen.getByRole( "button", { name: "Save On-Page SEO Checklist" } ) ); + fireEvent.click( screen.getByRole( "button", { name: "Save On-Page SEO Checklist" } ) ); + } ); expect( onApplyRow ).toHaveBeenCalledTimes( 1 ); expect( onApplyRow ).toHaveBeenCalledWith( 2 ); } ); it( "disables the row's inputs and actions while it is saving", () => { - render( + const { container } = render( { ); // While any field on the row is saving, the whole row is locked. - expect( screen.getByRole( "textbox", { name: "SEO title for On-Page SEO Checklist" } ) ).toBeDisabled(); - expect( screen.getByRole( "textbox", { name: "Meta description for On-Page SEO Checklist" } ) ).toBeDisabled(); + // Draft.js 0.11 sets contentEditable=false and drops role/aria-readonly in readOnly mode; + // ReplacementVariableEditor marks the wrapper with yst-replacevar--disabled instead. + expect( container.querySelectorAll( ".yst-replacevar--disabled" ) ).toHaveLength( 2 ); expect( screen.getByRole( "button", { name: "Save On-Page SEO Checklist" } ) ).toBeDisabled(); expect( screen.getByRole( "button", { name: "Cancel editing On-Page SEO Checklist" } ) ).toBeDisabled(); } ); @@ -385,7 +406,7 @@ describe( "BulkEditorTable", () => { /> ); - expect( screen.getByRole( "textbox", { name: "SEO title for What Is SEO" } ) ).toHaveValue( "First" ); - expect( screen.getByRole( "textbox", { name: "SEO title for On-Page SEO Checklist" } ) ).toHaveValue( "Second" ); + expect( screen.getByRole( "combobox", { name: "SEO title for What Is SEO" } ) ).toHaveTextContent( "First" ); + expect( screen.getByRole( "combobox", { name: "SEO title for On-Page SEO Checklist" } ) ).toHaveTextContent( "Second" ); } ); } ); diff --git a/packages/js/tests/bulk-editor/field-sets.test.js b/packages/js/tests/bulk-editor/field-sets.test.js index c8ee944f070..ce1e8a5cea8 100644 --- a/packages/js/tests/bulk-editor/field-sets.test.js +++ b/packages/js/tests/bulk-editor/field-sets.test.js @@ -23,8 +23,8 @@ describe( "getFieldSets", () => { expect( fieldSet.endpoint ).toBe( "update_search" ); expect( fieldSet.fields ).toEqual( [ { key: "focusKeyphrase", label: "Focus keyphrase", param: "focus_keyphrase", width: "sm:yst-w-[19%]" }, - { key: "seoTitle", label: "SEO title", param: "seo_title", width: "sm:yst-w-[19%]" }, - { key: "metaDescription", label: "Meta description", param: "meta_description", width: "sm:yst-w-[33%]" }, + { key: "seoTitle", label: "SEO title", param: "seo_title", width: "sm:yst-w-[19%]", type: "title" }, + { key: "metaDescription", label: "Meta description", param: "meta_description", width: "sm:yst-w-[33%]", type: "description" }, ] ); } ); @@ -34,8 +34,8 @@ describe( "getFieldSets", () => { expect( fieldSet.endpoint ).toBe( "update_social" ); expect( fieldSet.fields ).toEqual( [ { key: "focusKeyphrase", label: "Focus keyphrase", param: "focus_keyphrase", width: "sm:yst-w-[19%]" }, - { key: "socialTitle", label: "Social title", param: "social_title", width: "sm:yst-w-[19%]" }, - { key: "socialDescription", label: "Social description", param: "social_description", width: "sm:yst-w-[33%]" }, + { key: "socialTitle", label: "Social title", param: "social_title", width: "sm:yst-w-[19%]", type: "title" }, + { key: "socialDescription", label: "Social description", param: "social_description", width: "sm:yst-w-[33%]", type: "description" }, ] ); } ); diff --git a/packages/js/tests/bulk-editor/initialize.test.js b/packages/js/tests/bulk-editor/initialize.test.js index 552baac94fa..4f9d7d5af67 100644 --- a/packages/js/tests/bulk-editor/initialize.test.js +++ b/packages/js/tests/bulk-editor/initialize.test.js @@ -90,6 +90,12 @@ describe( "bulk editor initialize", () => { connectUrl: null, learnMoreUrl: "", }, + replacementVariables: { + recommended: {}, + shared: [], + specific: {}, + variables: [], + }, activeContentType: "", selection: { selectedIds: [], preselectedTotal: 0 }, query: { overviewIds: [], isOverviewFilterActive: false }, @@ -119,6 +125,7 @@ describe( "bulk editor initialize", () => { connectUrl: null, learnMoreUrl: "", }, + replacementVariables: { recommended: {}, shared: [], specific: {}, variables: [] }, activeContentType: "page", selection: { selectedIds: [ 5, 3 ], preselectedTotal: 25 }, query: { overviewIds: [ 5, 3 ], isOverviewFilterActive: true }, From cd839ce8df7e74c760c5966e7f7e7810ae821123 Mon Sep 17 00:00:00 2001 From: Vraja Das Date: Fri, 7 Aug 2026 15:34:48 +0300 Subject: [PATCH 14/34] tests: fix console warnings --- packages/js/tests/bulk-editor/app.test.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/js/tests/bulk-editor/app.test.js b/packages/js/tests/bulk-editor/app.test.js index 269bac9d78f..214b7ff46cc 100644 --- a/packages/js/tests/bulk-editor/app.test.js +++ b/packages/js/tests/bulk-editor/app.test.js @@ -146,12 +146,18 @@ describe( "App", () => { render( ); await openEditAndSwitch(); - fireEvent.click( screen.getByRole( "button", { name: "Continue without saving" } ) ); + // Both clicks commit a field-set switch (setActiveFieldSet), which re-triggers usePosts via + // the needsImprovementFields dep. Wrap in act so the fetch microtask is flushed before assertions. + await act( async() => { + fireEvent.click( screen.getByRole( "button", { name: "Continue without saving" } ) ); + } ); expect( screen.queryByText( "Unsaved changes" ) ).not.toBeInTheDocument(); expect( screen.getByRole( "tab", { name: "Social appearance" } ) ).toHaveAttribute( "aria-selected", "true" ); // Back on Search the row is no longer in edit mode. - fireEvent.click( screen.getByRole( "tab", { name: "Search appearance" } ) ); + await act( async() => { + fireEvent.click( screen.getByRole( "tab", { name: "Search appearance" } ) ); + } ); expect( screen.queryByRole( "combobox", { name: `SEO title for ${ rowTitle }` } ) ).not.toBeInTheDocument(); expect( screen.getByRole( "button", { name: `Edit ${ rowTitle }` } ) ).toBeEnabled(); } ); From c1cc52ffc56e29ad61e483600b34f1a2b947571b Mon Sep 17 00:00:00 2001 From: Vraja Das Date: Fri, 7 Aug 2026 15:39:26 +0300 Subject: [PATCH 15/34] tests: fix cs --- packages/js/tests/bulk-editor/app.test.js | 2 +- .../Bulk_Editor_Integration/Enqueue_Assets_Test.php | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/js/tests/bulk-editor/app.test.js b/packages/js/tests/bulk-editor/app.test.js index 214b7ff46cc..dd2465caeeb 100644 --- a/packages/js/tests/bulk-editor/app.test.js +++ b/packages/js/tests/bulk-editor/app.test.js @@ -252,7 +252,7 @@ describe( "App", () => { fireEvent.click( await screen.findByRole( "button", { name: `Edit ${ rowTitle }` } ) ); fireEvent.click( screen.getByRole( "button", { name: `Save ${ rowTitle }` } ) ); - + // The active tab's save endpoint is not configured, so no POST is made and the field stays open. expect( remote.fetchJson ).not.toHaveBeenCalledWith( expect.anything(), diff --git a/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Enqueue_Assets_Test.php b/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Enqueue_Assets_Test.php index e387413772c..976ed0dbbb5 100644 --- a/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Enqueue_Assets_Test.php +++ b/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Enqueue_Assets_Test.php @@ -84,8 +84,8 @@ static function ( $data ) use ( $content_types ) { && \array_key_exists( 'recommended', $data['replacementVariables'] ) && \array_key_exists( 'specific', $data['replacementVariables'] ) && \array_key_exists( 'shared', $data['replacementVariables'] ); - } - ) + }, + ), ); $this->instance->enqueue_assets(); From bd88b482871e65521cffa1d4c540546e7d8d7c60 Mon Sep 17 00:00:00 2001 From: vraja-pro Date: Fri, 7 Aug 2026 16:22:26 +0300 Subject: [PATCH 16/34] fix(bulk-editor): read social title/description templates from options instead of filter Mirrors resolve_seo_title and resolve_meta_description: reads social-title-{post_type} and social-description-{post_type} options directly so the replacement-variable editor receives the raw template string rather than an expanded or empty value. Co-Authored-By: Claude Sonnet 4.6 --- .../posts/default-template-resolver.php | 37 +++++------- .../Resolve_Social_Description_Test.php | 47 ++++----------- .../Resolve_Social_Title_Test.php | 59 ++++++++----------- .../Get_Posts_Test.php | 2 +- .../Get_Posts_Test.php | 2 +- 5 files changed, 53 insertions(+), 94 deletions(-) diff --git a/src/bulk-editor/infrastructure/posts/default-template-resolver.php b/src/bulk-editor/infrastructure/posts/default-template-resolver.php index c3f33a230fa..91d67dfffec 100644 --- a/src/bulk-editor/infrastructure/posts/default-template-resolver.php +++ b/src/bulk-editor/infrastructure/posts/default-template-resolver.php @@ -75,18 +75,17 @@ public function resolve_meta_description( int $post_id, string $post_type, strin } /** - * Returns the social title for a post, falling back to the post type's configured template when empty. + * Returns the raw social title template for a post, falling back to the post type's configured template when empty. * - * Mirrors `Social_Data_Provider::get_social_title_template()`: only resolves a template when - * OpenGraph is enabled, and delegates to the `wpseo_social_template_post_type` filter so that - * Premium can supply a value while Free — which cannot configure this setting — always gets an - * empty string. + * Returns the unresolved template string so the caller can display it in a replacement-variable editor. + * Only resolves a template when OpenGraph is enabled. + * Priority: stored value → user-configured post type template (`social-title-{post_type}`) → installation default. * - * @param int $post_id The post ID. + * @param int $post_id The post ID (unused; kept for a consistent method signature). * @param string $post_type The post type slug. * @param string $stored_value The raw stored social title (empty string when never explicitly saved). * - * @return string The resolved social title. + * @return string The raw template string, or an empty string when no template is configured. */ public function resolve_social_title( int $post_id, string $post_type, string $stored_value ): string { if ( $stored_value !== '' ) { @@ -97,26 +96,25 @@ public function resolve_social_title( int $post_id, string $post_type, string $s return ''; } - $template = (string) \apply_filters( 'wpseo_social_template_post_type', '', 'title', $post_type ); + $template = (string) $this->options_helper->get( 'social-title-' . $post_type, '' ); if ( $template === '' ) { - return ''; + $template = (string) $this->options_helper->get_title_default( 'social-title-' . $post_type ); } - return (string) \wpseo_replace_vars( $template, \get_post( $post_id ) ); + return $template; } /** - * Returns the social description for a post, falling back to the post type's configured template when empty. + * Returns the raw social description template for a post, falling back to the post type's configured template when empty. * - * Mirrors `Social_Data_Provider::get_social_description_template()`: only resolves a template when - * OpenGraph is enabled, and delegates to the `wpseo_social_template_post_type` filter so that - * Premium can supply a value while Free always gets an empty string. + * Returns the unresolved template string so the caller can display it in a replacement-variable editor. + * Only resolves a template when OpenGraph is enabled. Unlike social title there is no installation-level default. * - * @param int $post_id The post ID. + * @param int $post_id The post ID (unused; kept for a consistent method signature). * @param string $post_type The post type slug. * @param string $stored_value The raw stored social description (empty string when never explicitly saved). * - * @return string The resolved social description. + * @return string The raw template string, or an empty string when no template is configured. */ public function resolve_social_description( int $post_id, string $post_type, string $stored_value ): string { if ( $stored_value !== '' ) { @@ -127,11 +125,6 @@ public function resolve_social_description( int $post_id, string $post_type, str return ''; } - $template = (string) \apply_filters( 'wpseo_social_template_post_type', '', 'description', $post_type ); - if ( $template === '' ) { - return ''; - } - - return (string) \wpseo_replace_vars( $template, \get_post( $post_id ) ); + return (string) $this->options_helper->get( 'social-description-' . $post_type, '' ); } } diff --git a/tests/Unit/Bulk_Editor/Infrastructure/Posts/Default_Template_Resolver/Resolve_Social_Description_Test.php b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Default_Template_Resolver/Resolve_Social_Description_Test.php index 992557607ba..0cbc267ddf9 100644 --- a/tests/Unit/Bulk_Editor/Infrastructure/Posts/Default_Template_Resolver/Resolve_Social_Description_Test.php +++ b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Default_Template_Resolver/Resolve_Social_Description_Test.php @@ -4,9 +4,6 @@ // phpcs:disable Yoast.NamingConventions.NamespaceName.MaxExceeded namespace Yoast\WP\SEO\Tests\Unit\Bulk_Editor\Infrastructure\Posts\Default_Template_Resolver; -use Brain\Monkey\Filters; -use Brain\Monkey\Functions; - /** * Tests resolve_social_description. * @@ -17,7 +14,7 @@ final class Resolve_Social_Description_Test extends Abstract_Default_Template_Resolver_Test { /** - * Tests that a non-empty stored value is returned unchanged without touching options or filters. + * Tests that a non-empty stored value is returned unchanged without touching options. * * @return void */ @@ -37,60 +34,38 @@ public function test_returns_stored_value_when_not_empty() { public function test_returns_empty_when_opengraph_disabled() { $this->options_helper->expects( 'get' )->with( 'opengraph', false )->andReturn( false ); - Functions\expect( 'apply_filters' )->never(); - Functions\expect( 'wpseo_replace_vars' )->never(); - $result = $this->instance->resolve_social_description( 7, 'post', '' ); $this->assertSame( '', $result ); } /** - * Tests that an empty string is returned when OpenGraph is enabled but the filter returns no template. - * - * This is the expected behaviour on Free, where no callback is registered for - * `wpseo_social_template_post_type` and the filter therefore returns the default empty string. + * Tests that the raw user-configured template is returned when the stored value is empty. * * @return void */ - public function test_returns_empty_when_filter_returns_empty_template() { + public function test_returns_configured_template_when_stored_value_is_empty() { $this->options_helper->expects( 'get' )->with( 'opengraph', false )->andReturn( true ); - - Filters\expectApplied( 'wpseo_social_template_post_type' ) - ->once() - ->with( '', 'description', 'post' ) - ->andReturn( '' ); - - Functions\expect( 'get_post' )->never(); - Functions\expect( 'wpseo_replace_vars' )->never(); + $this->options_helper->expects( 'get' )->with( 'social-description-post', '' )->andReturn( '%%excerpt%%' ); $result = $this->instance->resolve_social_description( 7, 'post', '' ); - $this->assertSame( '', $result ); + $this->assertSame( '%%excerpt%%', $result ); } /** - * Tests that the filter-provided template is resolved when OpenGraph is enabled. + * Tests that an empty string is returned when no template is configured for the post type. * - * This is the expected behaviour on Premium, where a callback supplies the configured template. + * Unlike social title, social description has no installation-level default fallback. * * @return void */ - public function test_resolves_from_filter_template_when_opengraph_enabled() { - $post = (object) [ 'ID' => 7 ]; - + public function test_returns_empty_when_no_template_is_configured() { $this->options_helper->expects( 'get' )->with( 'opengraph', false )->andReturn( true ); + $this->options_helper->expects( 'get' )->with( 'social-description-page', '' )->andReturn( '' ); - Filters\expectApplied( 'wpseo_social_template_post_type' ) - ->once() - ->with( '', 'description', 'post' ) - ->andReturn( '%%excerpt%%' ); - - Functions\expect( 'get_post' )->once()->with( 7 )->andReturn( $post ); - Functions\expect( 'wpseo_replace_vars' )->once()->with( '%%excerpt%%', $post )->andReturn( 'The post excerpt.' ); + $result = $this->instance->resolve_social_description( 7, 'page', '' ); - $result = $this->instance->resolve_social_description( 7, 'post', '' ); - - $this->assertSame( 'The post excerpt.', $result ); + $this->assertSame( '', $result ); } } diff --git a/tests/Unit/Bulk_Editor/Infrastructure/Posts/Default_Template_Resolver/Resolve_Social_Title_Test.php b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Default_Template_Resolver/Resolve_Social_Title_Test.php index 99b1de0ca38..3958e619a35 100644 --- a/tests/Unit/Bulk_Editor/Infrastructure/Posts/Default_Template_Resolver/Resolve_Social_Title_Test.php +++ b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Default_Template_Resolver/Resolve_Social_Title_Test.php @@ -4,9 +4,6 @@ // phpcs:disable Yoast.NamingConventions.NamespaceName.MaxExceeded namespace Yoast\WP\SEO\Tests\Unit\Bulk_Editor\Infrastructure\Posts\Default_Template_Resolver; -use Brain\Monkey\Filters; -use Brain\Monkey\Functions; - /** * Tests resolve_social_title. * @@ -17,7 +14,7 @@ final class Resolve_Social_Title_Test extends Abstract_Default_Template_Resolver_Test { /** - * Tests that a non-empty stored value is returned unchanged without touching options or filters. + * Tests that a non-empty stored value is returned unchanged without touching options. * * @return void */ @@ -36,9 +33,7 @@ public function test_returns_stored_value_when_not_empty() { */ public function test_returns_empty_when_opengraph_disabled() { $this->options_helper->expects( 'get' )->with( 'opengraph', false )->andReturn( false ); - - Functions\expect( 'apply_filters' )->never(); - Functions\expect( 'wpseo_replace_vars' )->never(); + $this->options_helper->expects( 'get_title_default' )->never(); $result = $this->instance->resolve_social_title( 7, 'post', '' ); @@ -46,51 +41,47 @@ public function test_returns_empty_when_opengraph_disabled() { } /** - * Tests that an empty string is returned when OpenGraph is enabled but the filter returns no template. - * - * This is the expected behaviour on Free, where no callback is registered for - * `wpseo_social_template_post_type` and the filter therefore returns the default empty string. + * Tests that the raw user-configured template is returned when the stored value is empty. * * @return void */ - public function test_returns_empty_when_filter_returns_empty_template() { + public function test_returns_configured_template_when_stored_value_is_empty() { $this->options_helper->expects( 'get' )->with( 'opengraph', false )->andReturn( true ); - - Filters\expectApplied( 'wpseo_social_template_post_type' ) - ->once() - ->with( '', 'title', 'post' ) - ->andReturn( '' ); - - Functions\expect( 'get_post' )->never(); - Functions\expect( 'wpseo_replace_vars' )->never(); + $this->options_helper->expects( 'get' )->with( 'social-title-post', '' )->andReturn( '%%title%%' ); + $this->options_helper->expects( 'get_title_default' )->never(); $result = $this->instance->resolve_social_title( 7, 'post', '' ); - $this->assertSame( '', $result ); + $this->assertSame( '%%title%%', $result ); } /** - * Tests that the filter-provided template is resolved when OpenGraph is enabled. - * - * This is the expected behaviour on Premium, where a callback supplies the configured template. + * Tests that the installation default template is returned when the user has not configured one. * * @return void */ - public function test_resolves_from_filter_template_when_opengraph_enabled() { - $post = (object) [ 'ID' => 7 ]; - + public function test_returns_default_template_when_configured_template_is_empty() { $this->options_helper->expects( 'get' )->with( 'opengraph', false )->andReturn( true ); + $this->options_helper->expects( 'get' )->with( 'social-title-page', '' )->andReturn( '' ); + $this->options_helper->expects( 'get_title_default' )->with( 'social-title-page' )->andReturn( '%%title%%' ); - Filters\expectApplied( 'wpseo_social_template_post_type' ) - ->once() - ->with( '', 'title', 'post' ) - ->andReturn( '%%title%%' ); + $result = $this->instance->resolve_social_title( 7, 'page', '' ); - Functions\expect( 'get_post' )->once()->with( 7 )->andReturn( $post ); - Functions\expect( 'wpseo_replace_vars' )->once()->with( '%%title%%', $post )->andReturn( 'My post' ); + $this->assertSame( '%%title%%', $result ); + } + + /** + * Tests that an empty string is returned when neither a configured template nor an installation default exists. + * + * @return void + */ + public function test_returns_empty_when_no_template_exists() { + $this->options_helper->expects( 'get' )->with( 'opengraph', false )->andReturn( true ); + $this->options_helper->expects( 'get' )->with( 'social-title-post', '' )->andReturn( '' ); + $this->options_helper->expects( 'get_title_default' )->with( 'social-title-post' )->andReturn( '' ); $result = $this->instance->resolve_social_title( 7, 'post', '' ); - $this->assertSame( 'My post', $result ); + $this->assertSame( '', $result ); } } diff --git a/tests/Unit/Bulk_Editor/Infrastructure/Posts/Indexable_Posts_Collector/Get_Posts_Test.php b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Indexable_Posts_Collector/Get_Posts_Test.php index c3a6d1b0e4d..ed542a980be 100644 --- a/tests/Unit/Bulk_Editor/Infrastructure/Posts/Indexable_Posts_Collector/Get_Posts_Test.php +++ b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Indexable_Posts_Collector/Get_Posts_Test.php @@ -134,7 +134,7 @@ public function test_get_posts_resolves_template_when_stored_values_are_empty() } /** - * Tests that the social title and social description fall back to the resolved template when the + * Tests that the social title and social description fall back to the raw template when the * stored values are empty, and that the post is not flagged as needing improvement. * * @return void diff --git a/tests/Unit/Bulk_Editor/Infrastructure/Posts/Post_Meta_Posts_Collector/Get_Posts_Test.php b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Post_Meta_Posts_Collector/Get_Posts_Test.php index b04340d3c79..11a0b1ee82d 100644 --- a/tests/Unit/Bulk_Editor/Infrastructure/Posts/Post_Meta_Posts_Collector/Get_Posts_Test.php +++ b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Post_Meta_Posts_Collector/Get_Posts_Test.php @@ -218,7 +218,7 @@ static function ( $post_id, $key ) use ( $meta ) { } /** - * Tests that the social title and social description fall back to the resolved template when the + * Tests that the social title and social description fall back to the raw template when the * stored values are empty, and that the post is not flagged as needing improvement. * * @return void From 89b05d8cc53da627dd8efe1914cbf3fc313cb847 Mon Sep 17 00:00:00 2001 From: vraja-pro Date: Fri, 7 Aug 2026 16:33:34 +0300 Subject: [PATCH 17/34] docs(bulk-editor): clarify Default_Template_Resolver class docblock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Narrows the wording to reflect that only the first-level fallback (stored value → post type template) is applied, and explains why deeper rendering-chain fallbacks are intentionally omitted. Co-Authored-By: Claude Sonnet 4.6 --- .../infrastructure/posts/default-template-resolver.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/bulk-editor/infrastructure/posts/default-template-resolver.php b/src/bulk-editor/infrastructure/posts/default-template-resolver.php index 91d67dfffec..9bef93fe853 100644 --- a/src/bulk-editor/infrastructure/posts/default-template-resolver.php +++ b/src/bulk-editor/infrastructure/posts/default-template-resolver.php @@ -6,8 +6,12 @@ use Yoast\WP\SEO\Helpers\Options_Helper; /** - * Resolves a post's SEO/social fields from the post type's default template when the stored value - * is empty, matching the single-post editor's fallback behaviour. + * Resolves a post's SEO/social fields to the raw template string for display in a replacement-variable editor. + * + * When a post has no stored value, falls back to the post type's configured template from options. + * Only the first level of fallback is applied; deeper rendering-chain fallbacks (e.g. social description + * falling back to meta description or excerpt) are intentionally omitted — those produce dynamic values + * that cannot be represented as a raw template. */ class Default_Template_Resolver { From 6afa8a9051a8e26b10843ed9728dace144ecd218 Mon Sep 17 00:00:00 2001 From: vraja-pro Date: Fri, 7 Aug 2026 16:34:49 +0300 Subject: [PATCH 18/34] refactor(bulk-editor): extract private resolve() helper in Default_Template_Resolver The four public methods share identical structure; delegate to a single private resolve() method to remove ~50 duplicated lines and eliminate the copy-paste bug class the reviewer identified. Co-Authored-By: Claude Sonnet 4.6 --- .../posts/default-template-resolver.php | 55 +++++++++---------- 1 file changed, 27 insertions(+), 28 deletions(-) diff --git a/src/bulk-editor/infrastructure/posts/default-template-resolver.php b/src/bulk-editor/infrastructure/posts/default-template-resolver.php index 9bef93fe853..28ac1d35866 100644 --- a/src/bulk-editor/infrastructure/posts/default-template-resolver.php +++ b/src/bulk-editor/infrastructure/posts/default-template-resolver.php @@ -45,16 +45,7 @@ public function __construct( Options_Helper $options_helper ) { * @return string The raw template string, or an empty string when no template is configured. */ public function resolve_seo_title( int $post_id, string $post_type, string $stored_value ): string { - if ( $stored_value !== '' ) { - return $stored_value; - } - - $template = (string) $this->options_helper->get( 'title-' . $post_type, '' ); - if ( $template === '' ) { - $template = (string) $this->options_helper->get_title_default( 'title-' . $post_type ); - } - - return $template; + return $this->resolve( $post_type, $stored_value, 'title-', true ); } /** @@ -71,11 +62,7 @@ public function resolve_seo_title( int $post_id, string $post_type, string $stor * @return string The raw template string, or an empty string when no template is configured. */ public function resolve_meta_description( int $post_id, string $post_type, string $stored_value ): string { - if ( $stored_value !== '' ) { - return $stored_value; - } - - return (string) $this->options_helper->get( 'metadesc-' . $post_type, '' ); + return $this->resolve( $post_type, $stored_value, 'metadesc-', false ); } /** @@ -92,20 +79,11 @@ public function resolve_meta_description( int $post_id, string $post_type, strin * @return string The raw template string, or an empty string when no template is configured. */ public function resolve_social_title( int $post_id, string $post_type, string $stored_value ): string { - if ( $stored_value !== '' ) { - return $stored_value; - } - if ( $this->options_helper->get( 'opengraph', false ) !== true ) { return ''; } - $template = (string) $this->options_helper->get( 'social-title-' . $post_type, '' ); - if ( $template === '' ) { - $template = (string) $this->options_helper->get_title_default( 'social-title-' . $post_type ); - } - - return $template; + return $this->resolve( $post_type, $stored_value, 'social-title-', true ); } /** @@ -121,14 +99,35 @@ public function resolve_social_title( int $post_id, string $post_type, string $s * @return string The raw template string, or an empty string when no template is configured. */ public function resolve_social_description( int $post_id, string $post_type, string $stored_value ): string { + if ( $this->options_helper->get( 'opengraph', false ) !== true ) { + return ''; + } + + return $this->resolve( $post_type, $stored_value, 'social-description-', false ); + } + + /** + * Resolves a raw template string for a given post type, stored value, and option key prefix. + * + * @param string $post_type The post type slug. + * @param string $stored_value The raw stored value (empty string when never explicitly saved). + * @param string $option_key_prefix The option key prefix (e.g. `title-`, `metadesc-`). + * @param bool $use_installation_default Whether to fall back to the installation default when the configured template is empty. + * + * @return string The raw template string, or an empty string when no template is configured. + */ + private function resolve( string $post_type, string $stored_value, string $option_key_prefix, bool $use_installation_default ): string { if ( $stored_value !== '' ) { return $stored_value; } - if ( $this->options_helper->get( 'opengraph', false ) !== true ) { - return ''; + $option_key = $option_key_prefix . $post_type; + $template = (string) $this->options_helper->get( $option_key, '' ); + + if ( $use_installation_default && $template === '' ) { + $template = (string) $this->options_helper->get_title_default( $option_key ); } - return (string) $this->options_helper->get( 'social-description-' . $post_type, '' ); + return $template; } } From 7d982392a1160704d4a857f901112bcc2d50b7e6 Mon Sep 17 00:00:00 2001 From: vraja-pro Date: Fri, 7 Aug 2026 16:49:14 +0300 Subject: [PATCH 19/34] fix(bulk-editor): split editable field from display fallback to prevent template being saved to meta MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the resolver result (%%title%% %%sep%% %%sitename%% or similar) was sent as the draft value for seo_title/meta_description/social_title/social_description. Because startEdit opens all fields and save posts every open field, opening a row and clicking Save without editing would write the fallback template to postmeta — cutting the post loose from Search Appearance. Fix: send the raw stored value in the editable field (empty string when nothing is stored) and add *_fallback siblings that carry the post-type template. The read-only ReplacementVariableEditorStandalone shows stored || fallback; the draft and save payload only ever touch the raw stored value. Co-Authored-By: Claude Sonnet 4.6 --- .../bulk-editor/components/table/table-row.js | 2 +- .../js/src/bulk-editor/hooks/use-posts.js | 4 + .../tests/bulk-editor/hooks/use-posts.test.js | 8 ++ src/bulk-editor/domain/posts/post.php | 120 ++++++++++++------ .../posts/indexable-posts-collector.php | 29 +++-- .../posts/post-meta-posts-collector.php | 27 ++-- .../Bulk_Editor/Domain/Posts/Post_Test.php | 56 ++++---- .../Get_Posts_Test.php | 12 +- .../Get_Posts_Test.php | 12 +- 9 files changed, 183 insertions(+), 87 deletions(-) diff --git a/packages/js/src/bulk-editor/components/table/table-row.js b/packages/js/src/bulk-editor/components/table/table-row.js index fc46c97e58e..f70e8c424aa 100644 --- a/packages/js/src/bulk-editor/components/table/table-row.js +++ b/packages/js/src/bulk-editor/components/table/table-row.js @@ -121,7 +121,7 @@ export const BulkEditorRow = ( { __( "%1$s for %2$s", "wordpress-seo" ), field.label, item.title ) } ( { metaDescription: post.meta_description, socialTitle: post.social_title, socialDescription: post.social_description, + seoTitleFallback: post.seo_title_fallback ?? "", + metaDescriptionFallback: post.meta_description_fallback ?? "", + socialTitleFallback: post.social_title_fallback ?? "", + socialDescriptionFallback: post.social_description_fallback ?? "", editable: post.editable, needsImprovement: post.needs_improvement ?? {}, } ); diff --git a/packages/js/tests/bulk-editor/hooks/use-posts.test.js b/packages/js/tests/bulk-editor/hooks/use-posts.test.js index 8953eda2f14..bd44d8eafc7 100644 --- a/packages/js/tests/bulk-editor/hooks/use-posts.test.js +++ b/packages/js/tests/bulk-editor/hooks/use-posts.test.js @@ -149,6 +149,10 @@ describe( "usePosts", () => { meta_description: "A description.", social_title: "Social hello", social_description: "Social description.", + seo_title_fallback: "", + meta_description_fallback: "", + social_title_fallback: "", + social_description_fallback: "", editable: true, needs_improvement: { seo_title: false, meta_description: true, social_title: false, social_description: false }, }, @@ -174,6 +178,10 @@ describe( "usePosts", () => { metaDescription: "A description.", socialTitle: "Social hello", socialDescription: "Social description.", + seoTitleFallback: "", + metaDescriptionFallback: "", + socialTitleFallback: "", + socialDescriptionFallback: "", editable: true, // eslint-disable-next-line camelcase -- the needs-improvement map is keyed by backend field params. needsImprovement: { seo_title: false, meta_description: true, social_title: false, social_description: false }, diff --git a/src/bulk-editor/domain/posts/post.php b/src/bulk-editor/domain/posts/post.php index 8177bf85308..405043cef87 100644 --- a/src/bulk-editor/domain/posts/post.php +++ b/src/bulk-editor/domain/posts/post.php @@ -44,28 +44,28 @@ class Post { private $focus_keyphrase; /** - * The SEO title. + * The raw stored SEO title (empty string when never explicitly saved). * * @var string */ private $seo_title; /** - * The meta description. + * The raw stored meta description (empty string when never explicitly saved). * * @var string */ private $meta_description; /** - * The social title. + * The raw stored social title (empty string when never explicitly saved). * * @var string */ private $social_title; /** - * The social description. + * The raw stored social description (empty string when never explicitly saved). * * @var string */ @@ -86,20 +86,52 @@ class Post { */ private $needs_improvement; + /** + * The post type's SEO title template, shown when the stored value is empty. Empty string when the stored value is set. + * + * @var string + */ + private $seo_title_fallback; + + /** + * The post type's meta description template, shown when the stored value is empty. Empty string when the stored value is set. + * + * @var string + */ + private $meta_description_fallback; + + /** + * The post type's social title template, shown when the stored value is empty. Empty string when the stored value is set. + * + * @var string + */ + private $social_title_fallback; + + /** + * The post type's social description template, shown when the stored value is empty. Empty string when the stored value is set. + * + * @var string + */ + private $social_description_fallback; + /** * The constructor. * - * @param int $id The post ID. - * @param string $title The post title. - * @param string $status The post status. - * @param string $edit_link The URL to edit the post. - * @param string $focus_keyphrase The focus keyphrase. - * @param string $seo_title The SEO title. - * @param string $meta_description The meta description. - * @param string $social_title The social title. - * @param string $social_description The social description. - * @param bool $editable Whether the current user may edit this post. - * @param array $needs_improvement Whether each field needs improvement, keyed by field param. + * @param int $id The post ID. + * @param string $title The post title. + * @param string $status The post status. + * @param string $edit_link The URL to edit the post. + * @param string $focus_keyphrase The focus keyphrase. + * @param string $seo_title The raw stored SEO title. + * @param string $meta_description The raw stored meta description. + * @param string $social_title The raw stored social title. + * @param string $social_description The raw stored social description. + * @param bool $editable Whether the current user may edit this post. + * @param array $needs_improvement Whether each field needs improvement, keyed by field param. + * @param string $seo_title_fallback The post type's SEO title template (empty when stored value is set). + * @param string $meta_description_fallback The post type's meta description template (empty when stored value is set). + * @param string $social_title_fallback The post type's social title template (empty when stored value is set). + * @param string $social_description_fallback The post type's social description template (empty when stored value is set). */ public function __construct( int $id, @@ -112,19 +144,27 @@ public function __construct( string $social_title, string $social_description, bool $editable, - array $needs_improvement = [] + array $needs_improvement = [], + string $seo_title_fallback = '', + string $meta_description_fallback = '', + string $social_title_fallback = '', + string $social_description_fallback = '' ) { - $this->id = $id; - $this->title = $title; - $this->status = $status; - $this->edit_link = $edit_link; - $this->focus_keyphrase = $focus_keyphrase; - $this->seo_title = $seo_title; - $this->meta_description = $meta_description; - $this->social_title = $social_title; - $this->social_description = $social_description; - $this->editable = $editable; - $this->needs_improvement = $needs_improvement; + $this->id = $id; + $this->title = $title; + $this->status = $status; + $this->edit_link = $edit_link; + $this->focus_keyphrase = $focus_keyphrase; + $this->seo_title = $seo_title; + $this->meta_description = $meta_description; + $this->social_title = $social_title; + $this->social_description = $social_description; + $this->editable = $editable; + $this->needs_improvement = $needs_improvement; + $this->seo_title_fallback = $seo_title_fallback; + $this->meta_description_fallback = $meta_description_fallback; + $this->social_title_fallback = $social_title_fallback; + $this->social_description_fallback = $social_description_fallback; } /** @@ -134,17 +174,21 @@ public function __construct( */ public function to_array(): array { return [ - 'id' => $this->id, - 'title' => $this->title, - 'status' => $this->status, - 'edit_link' => $this->edit_link, - 'focus_keyphrase' => $this->focus_keyphrase, - 'seo_title' => $this->seo_title, - 'meta_description' => $this->meta_description, - 'social_title' => $this->social_title, - 'social_description' => $this->social_description, - 'editable' => $this->editable, - 'needs_improvement' => \array_merge( + 'id' => $this->id, + 'title' => $this->title, + 'status' => $this->status, + 'edit_link' => $this->edit_link, + 'focus_keyphrase' => $this->focus_keyphrase, + 'seo_title' => $this->seo_title, + 'meta_description' => $this->meta_description, + 'social_title' => $this->social_title, + 'social_description' => $this->social_description, + 'seo_title_fallback' => $this->seo_title_fallback, + 'meta_description_fallback' => $this->meta_description_fallback, + 'social_title_fallback' => $this->social_title_fallback, + 'social_description_fallback' => $this->social_description_fallback, + 'editable' => $this->editable, + 'needs_improvement' => \array_merge( [ 'seo_title' => false, 'meta_description' => false, diff --git a/src/bulk-editor/infrastructure/posts/indexable-posts-collector.php b/src/bulk-editor/infrastructure/posts/indexable-posts-collector.php index 182612ed574..ddc80fa69d7 100644 --- a/src/bulk-editor/infrastructure/posts/indexable-posts-collector.php +++ b/src/bulk-editor/infrastructure/posts/indexable-posts-collector.php @@ -274,12 +274,19 @@ private function build_post( Indexable $indexable, bool $editable, bool $scores_ return new Post( $object_id, $title, (string) $indexable->post_status, '', '', '', '', '', '', false ); } - $post_type = (string) $indexable->object_sub_type; + $post_type = (string) $indexable->object_sub_type; + + $raw_seo_title = (string) $indexable->title; + $raw_meta_description = (string) $indexable->description; + $raw_social_title = (string) $indexable->open_graph_title; + $raw_social_description = (string) $indexable->open_graph_description; + + // Resolver results are used for needs-improvement scoring and as display fallbacks when the stored value is empty. $resolved_values = [ - 'seo_title' => $this->default_template_resolver->resolve_seo_title( $object_id, $post_type, (string) $indexable->title ), - 'meta_description' => $this->default_template_resolver->resolve_meta_description( $object_id, $post_type, (string) $indexable->description ), - 'social_title' => $this->default_template_resolver->resolve_social_title( $object_id, $post_type, (string) $indexable->open_graph_title ), - 'social_description' => $this->default_template_resolver->resolve_social_description( $object_id, $post_type, (string) $indexable->open_graph_description ), + 'seo_title' => $this->default_template_resolver->resolve_seo_title( $object_id, $post_type, $raw_seo_title ), + 'meta_description' => $this->default_template_resolver->resolve_meta_description( $object_id, $post_type, $raw_meta_description ), + 'social_title' => $this->default_template_resolver->resolve_social_title( $object_id, $post_type, $raw_social_title ), + 'social_description' => $this->default_template_resolver->resolve_social_description( $object_id, $post_type, $raw_social_description ), ]; return new Post( @@ -288,12 +295,16 @@ private function build_post( Indexable $indexable, bool $editable, bool $scores_ (string) $indexable->post_status, (string) \get_edit_post_link( $object_id, 'raw' ), (string) $indexable->primary_focus_keyword, - $resolved_values['seo_title'], - $resolved_values['meta_description'], - $resolved_values['social_title'], - $resolved_values['social_description'], + $raw_seo_title, + $raw_meta_description, + $raw_social_title, + $raw_social_description, true, $this->build_needs_improvement( $indexable, $scores_enabled, $resolved_values ), + $raw_seo_title === '' ? $resolved_values['seo_title'] : '', + $raw_meta_description === '' ? $resolved_values['meta_description'] : '', + $raw_social_title === '' ? $resolved_values['social_title'] : '', + $raw_social_description === '' ? $resolved_values['social_description'] : '', ); } diff --git a/src/bulk-editor/infrastructure/posts/post-meta-posts-collector.php b/src/bulk-editor/infrastructure/posts/post-meta-posts-collector.php index 0f92fddfc94..925e76e6f55 100644 --- a/src/bulk-editor/infrastructure/posts/post-meta-posts-collector.php +++ b/src/bulk-editor/infrastructure/posts/post-meta-posts-collector.php @@ -237,11 +237,16 @@ private function build_post( int $post_id, bool $editable, bool $scores_enabled $fields[ $field ] = $this->get_meta( $post_id, $suffix ); } - // Fall back to the post type's default template when the stored value is empty. - $fields['seo_title'] = $this->default_template_resolver->resolve_seo_title( $post_id, $post_type, $fields['seo_title'] ); - $fields['meta_description'] = $this->default_template_resolver->resolve_meta_description( $post_id, $post_type, $fields['meta_description'] ); - $fields['social_title'] = $this->default_template_resolver->resolve_social_title( $post_id, $post_type, $fields['social_title'] ); - $fields['social_description'] = $this->default_template_resolver->resolve_social_description( $post_id, $post_type, $fields['social_description'] ); + $raw_seo_title = $fields['seo_title']; + $raw_meta_description = $fields['meta_description']; + $raw_social_title = $fields['social_title']; + $raw_social_description = $fields['social_description']; + + // Resolve templates for needs-improvement scoring and as display fallbacks when the stored value is empty. + $fields['seo_title'] = $this->default_template_resolver->resolve_seo_title( $post_id, $post_type, $raw_seo_title ); + $fields['meta_description'] = $this->default_template_resolver->resolve_meta_description( $post_id, $post_type, $raw_meta_description ); + $fields['social_title'] = $this->default_template_resolver->resolve_social_title( $post_id, $post_type, $raw_social_title ); + $fields['social_description'] = $this->default_template_resolver->resolve_social_description( $post_id, $post_type, $raw_social_description ); return new Post( $post_id, @@ -249,12 +254,16 @@ private function build_post( int $post_id, bool $editable, bool $scores_enabled $status, (string) \get_edit_post_link( $post_id, 'raw' ), $this->get_meta( $post_id, 'focuskw' ), - $fields['seo_title'], - $fields['meta_description'], - $fields['social_title'], - $fields['social_description'], + $raw_seo_title, + $raw_meta_description, + $raw_social_title, + $raw_social_description, true, $this->build_needs_improvement( $post_id, $fields, $scores_enabled ), + $raw_seo_title === '' ? $fields['seo_title'] : '', + $raw_meta_description === '' ? $fields['meta_description'] : '', + $raw_social_title === '' ? $fields['social_title'] : '', + $raw_social_description === '' ? $fields['social_description'] : '', ); } diff --git a/tests/Unit/Bulk_Editor/Domain/Posts/Post_Test.php b/tests/Unit/Bulk_Editor/Domain/Posts/Post_Test.php index fc126c474ef..56950cf701a 100644 --- a/tests/Unit/Bulk_Editor/Domain/Posts/Post_Test.php +++ b/tests/Unit/Bulk_Editor/Domain/Posts/Post_Test.php @@ -39,21 +39,29 @@ public function test_to_array() { 'social_title' => false, 'social_description' => true, ], + '', + '', + '', + '', ); $this->assertSame( [ - 'id' => 7, - 'title' => 'Hello world', - 'status' => 'draft', - 'edit_link' => 'post.php?post=7&action=edit', - 'focus_keyphrase' => 'hello', - 'seo_title' => 'Hello | Site', - 'meta_description' => 'A description.', - 'social_title' => 'Social hello', - 'social_description' => 'Social description.', - 'editable' => true, - 'needs_improvement' => [ + 'id' => 7, + 'title' => 'Hello world', + 'status' => 'draft', + 'edit_link' => 'post.php?post=7&action=edit', + 'focus_keyphrase' => 'hello', + 'seo_title' => 'Hello | Site', + 'meta_description' => 'A description.', + 'social_title' => 'Social hello', + 'social_description' => 'Social description.', + 'seo_title_fallback' => '', + 'meta_description_fallback' => '', + 'social_title_fallback' => '', + 'social_description_fallback' => '', + 'editable' => true, + 'needs_improvement' => [ 'seo_title' => false, 'meta_description' => true, 'social_title' => false, @@ -74,17 +82,21 @@ public function test_to_array_not_editable() { $this->assertSame( [ - 'id' => 7, - 'title' => 'Hello world', - 'status' => 'draft', - 'edit_link' => '', - 'focus_keyphrase' => '', - 'seo_title' => '', - 'meta_description' => '', - 'social_title' => '', - 'social_description' => '', - 'editable' => false, - 'needs_improvement' => [ + 'id' => 7, + 'title' => 'Hello world', + 'status' => 'draft', + 'edit_link' => '', + 'focus_keyphrase' => '', + 'seo_title' => '', + 'meta_description' => '', + 'social_title' => '', + 'social_description' => '', + 'seo_title_fallback' => '', + 'meta_description_fallback' => '', + 'social_title_fallback' => '', + 'social_description_fallback' => '', + 'editable' => false, + 'needs_improvement' => [ 'seo_title' => false, 'meta_description' => false, 'social_title' => false, diff --git a/tests/Unit/Bulk_Editor/Infrastructure/Posts/Indexable_Posts_Collector/Get_Posts_Test.php b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Indexable_Posts_Collector/Get_Posts_Test.php index ed542a980be..70017fdacc2 100644 --- a/tests/Unit/Bulk_Editor/Infrastructure/Posts/Indexable_Posts_Collector/Get_Posts_Test.php +++ b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Indexable_Posts_Collector/Get_Posts_Test.php @@ -127,8 +127,10 @@ public function test_get_posts_resolves_template_when_stored_values_are_empty() $result = $this->instance->get_posts( new Posts_Query( 'page', 1, 20, '', self::STATUSES ) )->to_array(); $post = $result['posts'][0]; - $this->assertSame( 'Page title from template', $post['seo_title'] ); - $this->assertSame( 'Page description from template', $post['meta_description'] ); + $this->assertSame( '', $post['seo_title'] ); + $this->assertSame( '', $post['meta_description'] ); + $this->assertSame( 'Page title from template', $post['seo_title_fallback'] ); + $this->assertSame( 'Page description from template', $post['meta_description_fallback'] ); $this->assertFalse( $post['needs_improvement']['seo_title'] ); $this->assertFalse( $post['needs_improvement']['meta_description'] ); } @@ -170,8 +172,10 @@ public function test_get_posts_resolves_social_template_when_stored_values_are_e $result = $this->instance->get_posts( new Posts_Query( 'page', 1, 20, '', self::STATUSES ) )->to_array(); $post = $result['posts'][0]; - $this->assertSame( 'Social title from template', $post['social_title'] ); - $this->assertSame( 'Social description from template', $post['social_description'] ); + $this->assertSame( '', $post['social_title'] ); + $this->assertSame( '', $post['social_description'] ); + $this->assertSame( 'Social title from template', $post['social_title_fallback'] ); + $this->assertSame( 'Social description from template', $post['social_description_fallback'] ); $this->assertFalse( $post['needs_improvement']['social_title'] ); $this->assertFalse( $post['needs_improvement']['social_description'] ); } diff --git a/tests/Unit/Bulk_Editor/Infrastructure/Posts/Post_Meta_Posts_Collector/Get_Posts_Test.php b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Post_Meta_Posts_Collector/Get_Posts_Test.php index 11a0b1ee82d..8e0336c4d1d 100644 --- a/tests/Unit/Bulk_Editor/Infrastructure/Posts/Post_Meta_Posts_Collector/Get_Posts_Test.php +++ b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Post_Meta_Posts_Collector/Get_Posts_Test.php @@ -211,8 +211,10 @@ static function ( $post_id, $key ) use ( $meta ) { $result = $this->instance->get_posts( new Posts_Query( 'page', 1, 20, '', self::STATUSES ) )->to_array(); $post = $result['posts'][0]; - $this->assertSame( 'Page title from template', $post['seo_title'] ); - $this->assertSame( 'Page description from template', $post['meta_description'] ); + $this->assertSame( '', $post['seo_title'] ); + $this->assertSame( '', $post['meta_description'] ); + $this->assertSame( 'Page title from template', $post['seo_title_fallback'] ); + $this->assertSame( 'Page description from template', $post['meta_description_fallback'] ); $this->assertFalse( $post['needs_improvement']['seo_title'] ); $this->assertFalse( $post['needs_improvement']['meta_description'] ); } @@ -264,8 +266,10 @@ static function ( $post_id, $key ) use ( $meta ) { $result = $this->instance->get_posts( new Posts_Query( 'page', 1, 20, '', self::STATUSES ) )->to_array(); $post = $result['posts'][0]; - $this->assertSame( 'Social title from template', $post['social_title'] ); - $this->assertSame( 'Social description from template', $post['social_description'] ); + $this->assertSame( '', $post['social_title'] ); + $this->assertSame( '', $post['social_description'] ); + $this->assertSame( 'Social title from template', $post['social_title_fallback'] ); + $this->assertSame( 'Social description from template', $post['social_description_fallback'] ); $this->assertFalse( $post['needs_improvement']['social_title'] ); $this->assertFalse( $post['needs_improvement']['social_description'] ); } From 8cc785f10841eab51542bb43a69c156e2848ea52 Mon Sep 17 00:00:00 2001 From: vraja-pro Date: Fri, 7 Aug 2026 16:58:11 +0300 Subject: [PATCH 20/34] fix(bulk-editor): exclude template-defaulted posts from needs-improvement query MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The row-level flag already used the resolved value, but the SQL WHERE clause still checked for NULL/empty stored values — so template-defaulted posts appeared in the "needs improvement" filter even though they were flagged as fine. Fix: pass the post type into apply_needs_improvement() and build_needs_improvement_where(); call the resolver with an empty stored value to detect whether the post type has a configured template. When it does, the empty-value predicate is replaced with a false condition (1=0 / 0=1) so those posts are excluded. The score predicate is kept when scoring is enabled. Co-Authored-By: Claude Sonnet 4.6 --- .../posts/indexable-posts-collector.php | 38 +++++++++---- .../posts/post-meta-posts-collector.php | 39 ++++++++------ .../Build_Needs_Improvement_Where_Test.php | 54 ++++++++++++++++++- .../Post_Meta_Posts_Collector_Double.php | 4 +- 4 files changed, 105 insertions(+), 30 deletions(-) diff --git a/src/bulk-editor/infrastructure/posts/indexable-posts-collector.php b/src/bulk-editor/infrastructure/posts/indexable-posts-collector.php index ddc80fa69d7..02cf307e70e 100644 --- a/src/bulk-editor/infrastructure/posts/indexable-posts-collector.php +++ b/src/bulk-editor/infrastructure/posts/indexable-posts-collector.php @@ -172,7 +172,7 @@ private function build_query( Posts_Query $query ): ORM { } if ( $query->get_needs_improvement() !== [] ) { - $this->apply_needs_improvement( $builder, $query->get_needs_improvement(), $query->are_scores_enabled() ); + $this->apply_needs_improvement( $builder, $query->get_needs_improvement(), $query->are_scores_enabled(), $query->get_content_type() ); } return $builder; @@ -183,16 +183,26 @@ private function build_query( Posts_Query $query ): ORM { * * A field needs improvement when its indexable column is NULL or an empty string, or — for fields * with a persisted per-field score and while scoring is enabled — when that score falls in the bad/ok - * range. The selected fields are OR-ed inside a single group so they broaden the result without - * interfering with the other filters, and unknown field keys are ignored. + * range. The empty-value check is skipped for fields whose post type has a configured fallback template, + * since template-defaulted posts are not genuinely empty. The selected fields are OR-ed inside a single + * group so they broaden the result without interfering with the other filters, and unknown field keys + * are ignored. * * @param ORM $builder The query to add the clause to. * @param array $fields The fields that need improvement. * @param bool $scores_enabled Whether the per-field scores may back the filter. + * @param string $post_type The post type slug. * * @return void */ - private function apply_needs_improvement( ORM $builder, array $fields, bool $scores_enabled ): void { + private function apply_needs_improvement( ORM $builder, array $fields, bool $scores_enabled, string $post_type ): void { + $has_fallback = [ + 'seo_title' => $this->default_template_resolver->resolve_seo_title( 0, $post_type, '' ) !== '', + 'meta_description' => $this->default_template_resolver->resolve_meta_description( 0, $post_type, '' ) !== '', + 'social_title' => $this->default_template_resolver->resolve_social_title( 0, $post_type, '' ) !== '', + 'social_description' => $this->default_template_resolver->resolve_social_description( 0, $post_type, '' ) !== '', + ]; + $clauses = []; $values = []; foreach ( $fields as $field ) { @@ -200,17 +210,23 @@ private function apply_needs_improvement( ORM $builder, array $fields, bool $sco continue; } - $column = self::FIELD_COLUMNS[ $field ]; - $clause = $column . ' IS NULL OR ' . $column . ' = %s'; - $values[] = ''; + $column = self::FIELD_COLUMNS[ $field ]; + $field_clauses = []; + + if ( ! ( $has_fallback[ $field ] ?? false ) ) { + $field_clauses[] = $column . ' IS NULL OR ' . $column . ' = %s'; + $values[] = ''; + } if ( $scores_enabled && isset( self::FIELD_SCORE_COLUMNS[ $field ] ) ) { - $clause .= ' OR ' . self::FIELD_SCORE_COLUMNS[ $field ] . ' BETWEEN %d AND %d'; - $values[] = self::NEEDS_IMPROVEMENT_MIN_SCORE; - $values[] = self::NEEDS_IMPROVEMENT_MAX_SCORE; + $field_clauses[] = self::FIELD_SCORE_COLUMNS[ $field ] . ' BETWEEN %d AND %d'; + $values[] = self::NEEDS_IMPROVEMENT_MIN_SCORE; + $values[] = self::NEEDS_IMPROVEMENT_MAX_SCORE; } - $clauses[] = '( ' . $clause . ' )'; + // Always add a clause per field — use a false condition when no real predicate applies so the + // field still participates in the outer OR group without incorrectly matching every row. + $clauses[] = '( ' . ( $field_clauses !== [] ? \implode( ' OR ', $field_clauses ) : '1 = 0' ) . ' )'; } if ( $clauses === [] ) { diff --git a/src/bulk-editor/infrastructure/posts/post-meta-posts-collector.php b/src/bulk-editor/infrastructure/posts/post-meta-posts-collector.php index 925e76e6f55..6557ce850cd 100644 --- a/src/bulk-editor/infrastructure/posts/post-meta-posts-collector.php +++ b/src/bulk-editor/infrastructure/posts/post-meta-posts-collector.php @@ -136,7 +136,7 @@ protected function run_query( Posts_Query $query ): WP_Query { $args = $this->build_query_args( $query ); $this->search_where = $query->has_search() ? $this->build_search_where( $query->get_search() ) : ''; - $this->needs_improvement_where = $this->build_needs_improvement_where( $query->get_needs_improvement(), $query->are_scores_enabled() ); + $this->needs_improvement_where = $this->build_needs_improvement_where( $query->get_needs_improvement(), $query->are_scores_enabled(), $query->get_content_type() ); if ( $this->search_where === '' && $this->needs_improvement_where === '' ) { return new WP_Query( $args ); @@ -312,42 +312,49 @@ private function build_needs_improvement( int $post_id, array $fields, bool $sco * * @return string The prepared WHERE clause, or an empty string when no known field is selected. */ - protected function build_needs_improvement_where( array $fields, bool $scores_enabled ): string { + protected function build_needs_improvement_where( array $fields, bool $scores_enabled, string $post_type = '' ): string { global $wpdb; + $has_fallback = [ + 'seo_title' => $this->default_template_resolver->resolve_seo_title( 0, $post_type, '' ) !== '', + 'meta_description' => $this->default_template_resolver->resolve_meta_description( 0, $post_type, '' ) !== '', + 'social_title' => $this->default_template_resolver->resolve_social_title( 0, $post_type, '' ) !== '', + 'social_description' => $this->default_template_resolver->resolve_social_description( 0, $post_type, '' ) !== '', + ]; + $clauses = []; foreach ( $fields as $field ) { if ( ! isset( self::FIELD_META_SUFFIXES[ $field ] ) ) { continue; } - $meta_key = self::META_PREFIX . self::FIELD_META_SUFFIXES[ $field ]; + $meta_key = self::META_PREFIX . self::FIELD_META_SUFFIXES[ $field ]; + $field_clauses = []; - if ( $scores_enabled && isset( self::FIELD_SCORE_META_SUFFIXES[ $field ] ) ) { - $clauses[] = $wpdb->prepare( - '( %i.ID NOT IN ( SELECT post_id FROM %i WHERE meta_key = %s AND meta_value <> %s )' - . ' OR %i.ID IN ( SELECT post_id FROM %i WHERE meta_key = %s AND CAST( meta_value AS SIGNED ) BETWEEN %d AND %d ) )', + if ( ! ( $has_fallback[ $field ] ?? false ) ) { + $field_clauses[] = $wpdb->prepare( + '%i.ID NOT IN ( SELECT post_id FROM %i WHERE meta_key = %s AND meta_value <> %s )', $wpdb->posts, $wpdb->postmeta, $meta_key, '', + ); + } + + if ( $scores_enabled && isset( self::FIELD_SCORE_META_SUFFIXES[ $field ] ) ) { + $field_clauses[] = $wpdb->prepare( + '%i.ID IN ( SELECT post_id FROM %i WHERE meta_key = %s AND CAST( meta_value AS SIGNED ) BETWEEN %d AND %d )', $wpdb->posts, $wpdb->postmeta, self::META_PREFIX . self::FIELD_SCORE_META_SUFFIXES[ $field ], self::NEEDS_IMPROVEMENT_MIN_SCORE, self::NEEDS_IMPROVEMENT_MAX_SCORE, ); - - continue; } - $clauses[] = $wpdb->prepare( - '( %i.ID NOT IN ( SELECT post_id FROM %i WHERE meta_key = %s AND meta_value <> %s ) )', - $wpdb->posts, - $wpdb->postmeta, - $meta_key, - '', - ); + // Always add a clause per field — use a false condition when no real predicate applies so the + // field still participates in the outer OR group without incorrectly matching every row. + $clauses[] = '( ' . ( $field_clauses !== [] ? \implode( ' OR ', $field_clauses ) : '0 = 1' ) . ' )'; } if ( $clauses === [] ) { diff --git a/tests/Unit/Bulk_Editor/Infrastructure/Posts/Post_Meta_Posts_Collector/Build_Needs_Improvement_Where_Test.php b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Post_Meta_Posts_Collector/Build_Needs_Improvement_Where_Test.php index 2d6fc25bd5e..4c3ac11ccd7 100644 --- a/tests/Unit/Bulk_Editor/Infrastructure/Posts/Post_Meta_Posts_Collector/Build_Needs_Improvement_Where_Test.php +++ b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Post_Meta_Posts_Collector/Build_Needs_Improvement_Where_Test.php @@ -26,6 +26,13 @@ final class Build_Needs_Improvement_Where_Test extends TestCase { */ private $instance; + /** + * The default template resolver mock. + * + * @var Default_Template_Resolver&\Mockery\MockInterface + */ + private $default_template_resolver; + /** * Sets up the test fixtures. * @@ -43,9 +50,17 @@ protected function set_up() { $wpdb->postmeta = 'wp_postmeta'; $wpdb->allows( 'prepare' )->andReturnUsing( [ $this, 'interpolate_query' ] ); + $this->default_template_resolver = Mockery::mock( Default_Template_Resolver::class ); + + // Return empty string by default — no fallback template configured — so the empty-value clause is kept. + $this->default_template_resolver->allows( 'resolve_seo_title' )->andReturn( '' )->byDefault(); + $this->default_template_resolver->allows( 'resolve_meta_description' )->andReturn( '' )->byDefault(); + $this->default_template_resolver->allows( 'resolve_social_title' )->andReturn( '' )->byDefault(); + $this->default_template_resolver->allows( 'resolve_social_description' )->andReturn( '' )->byDefault(); + $this->instance = new Post_Meta_Posts_Collector_Double( Mockery::mock( Post_Editability_Resolver::class ), - Mockery::mock( Default_Template_Resolver::class ), + $this->default_template_resolver, ); } @@ -151,4 +166,41 @@ public function test_returns_empty_string_for_no_known_fields() { $this->assertSame( '', $this->instance->expose_build_needs_improvement_where( [], true ) ); $this->assertSame( '', $this->instance->expose_build_needs_improvement_where( [ 'unknown_field' ], true ) ); } + + /** + * Tests that a field with a post-type fallback template is excluded from the empty-value clause. + * + * When the post type has a configured template for a field, template-defaulted posts are not genuinely + * empty and should not appear in the "needs improvement" filter. The empty-value clause is replaced + * with a false condition so those posts are excluded while the OR-group structure is preserved. + * + * @return void + */ + public function test_skips_empty_clause_when_post_type_has_fallback_template() { + $this->default_template_resolver->allows( 'resolve_seo_title' ) + ->with( 0, 'post', '' ) + ->andReturn( '%%title%% %%sep%% %%sitename%%' ); + + $where = $this->instance->expose_build_needs_improvement_where( [ 'seo_title' ], false, 'post' ); + + $this->assertStringNotContainsString( '_yoast_wpseo_title', $where ); + $this->assertStringContainsString( '0 = 1', $where ); + } + + /** + * Tests that only the score clause is kept for a field with a template when scoring is enabled. + * + * @return void + */ + public function test_keeps_score_clause_for_templated_field_when_scoring_enabled() { + $this->default_template_resolver->allows( 'resolve_seo_title' ) + ->with( 0, 'post', '' ) + ->andReturn( '%%title%% %%sep%% %%sitename%%' ); + + $where = $this->instance->expose_build_needs_improvement_where( [ 'seo_title' ], true, 'post' ); + + $this->assertStringNotContainsString( "meta_key = '_yoast_wpseo_title' AND meta_value <> ''", $where ); + $this->assertStringContainsString( '_yoast_wpseo_seo_title_score', $where ); + $this->assertStringContainsString( 'BETWEEN', $where ); + } } diff --git a/tests/Unit/Doubles/Bulk_Editor/Post_Meta_Posts_Collector_Double.php b/tests/Unit/Doubles/Bulk_Editor/Post_Meta_Posts_Collector_Double.php index 620854f8839..c3e62a7052a 100644 --- a/tests/Unit/Doubles/Bulk_Editor/Post_Meta_Posts_Collector_Double.php +++ b/tests/Unit/Doubles/Bulk_Editor/Post_Meta_Posts_Collector_Double.php @@ -17,7 +17,7 @@ class Post_Meta_Posts_Collector_Double extends Post_Meta_Posts_Collector { * * @return string The prepared WHERE clause. */ - public function expose_build_needs_improvement_where( array $fields, bool $scores_enabled ): string { - return $this->build_needs_improvement_where( $fields, $scores_enabled ); + public function expose_build_needs_improvement_where( array $fields, bool $scores_enabled, string $post_type = '' ): string { + return $this->build_needs_improvement_where( $fields, $scores_enabled, $post_type ); } } From c91aed3ce3c2e569e87698911a550a05cb6a940d Mon Sep 17 00:00:00 2001 From: vraja-pro Date: Fri, 7 Aug 2026 17:01:28 +0300 Subject: [PATCH 21/34] fix(bulk-editor): remove unreachable ?? fallback in build_needs_improvement All four keys are always present in $resolved_values; the ?? arm was dead code that would silently hide a future bug if a fifth field were added. Co-Authored-By: Claude Sonnet 4.6 --- .../infrastructure/posts/indexable-posts-collector.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bulk-editor/infrastructure/posts/indexable-posts-collector.php b/src/bulk-editor/infrastructure/posts/indexable-posts-collector.php index 02cf307e70e..37050d5514a 100644 --- a/src/bulk-editor/infrastructure/posts/indexable-posts-collector.php +++ b/src/bulk-editor/infrastructure/posts/indexable-posts-collector.php @@ -340,7 +340,7 @@ private function build_post( Indexable $indexable, bool $editable, bool $scores_ private function build_needs_improvement( Indexable $indexable, bool $scores_enabled, array $resolved_values ): array { $needs_improvement = []; foreach ( self::FIELD_COLUMNS as $field => $column ) { - $value = ( $resolved_values[ $field ] ?? (string) $indexable->{$column} ); + $value = $resolved_values[ $field ]; $is_empty = ( $value === '' ); $is_bad_score = false; From 964145066dbbffbe6e52cf46229d64f13f9e252b Mon Sep 17 00:00:00 2001 From: vraja-pro Date: Fri, 7 Aug 2026 17:08:03 +0300 Subject: [PATCH 22/34] tests(bulk-editor): add missing needs-improvement and null-post coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three gaps identified in review: 1. No template configured → seo_title flag must be true. Added test_get_posts_flags_seo_title_when_no_template_configured_and_stored_value_is_empty. 2. SQL filter and row flag must agree. Added test_filter_and_row_flag_agree_when_post_type_has_seo_title_template — a single test that asserts both the 1=0 WHERE clause and the false needs_improvement flag for a template-defaulted post. This would have caught the mismatch flagged in review. 3. get_post() returning null in the post-meta collector. Added test_get_posts_handles_null_get_post_gracefully. Co-Authored-By: Claude Sonnet 4.6 --- .../Get_Posts_Test.php | 105 ++++++++++++++++++ .../Get_Posts_Test.php | 28 +++++ 2 files changed, 133 insertions(+) diff --git a/tests/Unit/Bulk_Editor/Infrastructure/Posts/Indexable_Posts_Collector/Get_Posts_Test.php b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Indexable_Posts_Collector/Get_Posts_Test.php index 70017fdacc2..7958872ad03 100644 --- a/tests/Unit/Bulk_Editor/Infrastructure/Posts/Indexable_Posts_Collector/Get_Posts_Test.php +++ b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Indexable_Posts_Collector/Get_Posts_Test.php @@ -377,6 +377,111 @@ public function test_get_posts_restricts_to_the_included_post_ids() { $this->assertSame( 1, $result['total'] ); } + /** + * Tests that a post with an empty stored SEO title is flagged as needing improvement when no + * post-type template is configured. + * + * @return void + */ + public function test_get_posts_flags_seo_title_when_no_template_configured_and_stored_value_is_empty() { + $indexable = new Indexable_Mock(); + $indexable->object_id = 7; + $indexable->object_sub_type = 'page'; + $indexable->post_status = 'draft'; + $indexable->primary_focus_keyword = ''; + $indexable->title = ''; // No stored value. + $indexable->description = 'Explicit description.'; + $indexable->open_graph_title = ''; + $indexable->open_graph_description = ''; + $indexable->seo_title_score = 0; + $indexable->meta_description_score = 0; + + $query = $this->stub_page_query( [ $indexable ] ); + $query->expects( 'count' )->never(); + + $this->post_editability_resolver->expects( 'resolve' )->with( [ 7 ] )->andReturn( [ 7 => true ] ); + + Functions\expect( 'get_the_title' )->once()->with( 7 )->andReturn( 'A page' ); + Functions\expect( 'get_edit_post_link' )->once()->with( 7, 'raw' )->andReturn( 'post.php?post=7&action=edit' ); + + $result = $this->instance->get_posts( new Posts_Query( 'page', 1, 20, '', self::STATUSES ) )->to_array(); + $post = $result['posts'][0]; + + // No template configured: the resolver returns '' → the flag must be true. + $this->assertTrue( $post['needs_improvement']['seo_title'] ); + // Stored description is non-empty, so meta description does not need improvement. + $this->assertFalse( $post['needs_improvement']['meta_description'] ); + } + + /** + * Tests that the needs-improvement SQL filter and the per-row flag agree when the post type has a + * configured SEO title template. + * + * The SQL filter must exclude template-defaulted posts (1 = 0, not IS NULL) and the row flag must + * report them as not needing improvement. A mismatch between the two caused the bug in #23438. + * + * @return void + */ + public function test_filter_and_row_flag_agree_when_post_type_has_seo_title_template() { + $indexable = new Indexable_Mock(); + $indexable->object_id = 7; + $indexable->object_sub_type = 'page'; + $indexable->post_status = 'draft'; + $indexable->primary_focus_keyword = ''; + $indexable->title = ''; // No stored value; fallback template applies. + $indexable->description = ''; + $indexable->open_graph_title = ''; + $indexable->open_graph_description = ''; + $indexable->seo_title_score = 0; + $indexable->meta_description_score = 0; + + // Resolver returns a template for seo_title when called with any post_id, 'page', ''. + $this->default_template_resolver->allows( 'resolve_seo_title' ) + ->with( Mockery::any(), 'page', '' ) + ->andReturn( '%%title%% %%sep%% %%sitename%%' ); + + $captured = []; + $query = Mockery::mock( ORM::class ); + $query->allows( 'where' )->andReturnSelf(); + $query->allows( 'where_in' )->andReturnSelf(); + $query->allows( 'order_by_desc' )->andReturnSelf(); + $query->allows( 'limit' )->andReturnSelf(); + $query->allows( 'offset' )->andReturnSelf(); + // Non-full page (1 row < per_page 20): resolve_total skips the count query, so where_raw fires once. + $query->expects( 'where_raw' ) + ->once() + ->with( + Mockery::on( + static function ( $clause ) use ( &$captured ) { + $captured[] = $clause; + + return true; + }, + ), + [], // Template configured, scoring disabled — no bound values. + ) + ->andReturnSelf(); + $query->expects( 'find_many' )->once()->andReturn( [ $indexable ] ); + + $this->indexable_repository->allows( 'query' )->andReturn( $query ); + $this->post_editability_resolver->expects( 'resolve' )->with( [ 7 ] )->andReturn( [ 7 => true ] ); + + Functions\expect( 'get_the_title' )->once()->with( 7 )->andReturn( 'A page' ); + Functions\expect( 'get_edit_post_link' )->once()->with( 7, 'raw' )->andReturn( 'post.php?post=7&action=edit' ); + + $result = $this->instance->get_posts( + new Posts_Query( 'page', 1, 20, '', self::STATUSES, null, [ 'seo_title' ], false ), + )->to_array(); + $post = $result['posts'][0]; + + // SQL: the filter must use the false condition, not an empty-column check. + $this->assertStringContainsString( '1 = 0', $captured[0] ); + $this->assertStringNotContainsString( 'title IS NULL', $captured[0] ); + + // Row flag: the post is not needing improvement — the template covers the gap. + $this->assertFalse( $post['needs_improvement']['seo_title'] ); + } + /** * Stubs the indexable query for a page that returns the given rows, without constraining count(). * diff --git a/tests/Unit/Bulk_Editor/Infrastructure/Posts/Post_Meta_Posts_Collector/Get_Posts_Test.php b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Post_Meta_Posts_Collector/Get_Posts_Test.php index 8e0336c4d1d..3fe482349a9 100644 --- a/tests/Unit/Bulk_Editor/Infrastructure/Posts/Post_Meta_Posts_Collector/Get_Posts_Test.php +++ b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Post_Meta_Posts_Collector/Get_Posts_Test.php @@ -274,6 +274,34 @@ static function ( $post_id, $key ) use ( $meta ) { $this->assertFalse( $post['needs_improvement']['social_description'] ); } + /** + * Tests that the collector handles a null get_post() return gracefully when the post is editable. + * + * get_post() returns null when the post has been deleted between the WP_Query and the per-row fetch. + * In that case post_type falls back to '' and the resolver is called with an empty post type; the + * collector must not crash and must return a post with empty status. + * + * @return void + */ + public function test_get_posts_handles_null_get_post_gracefully() { + $this->stub_run_query( [ 7 ], 1 ); + + $this->post_editability_resolver->expects( 'resolve' )->with( [ 7 ] )->andReturn( [ 7 => true ] ); + + Functions\expect( 'get_post' )->once()->with( 7 )->andReturnNull(); + Functions\expect( 'get_the_title' )->once()->with( 7 )->andReturn( 'A page' ); + Functions\expect( 'get_edit_post_link' )->once()->with( 7, 'raw' )->andReturn( 'post.php?post=7&action=edit' ); + Functions\expect( 'get_post_meta' )->times( 7 )->andReturn( '' ); + + $result = $this->instance->get_posts( new Posts_Query( 'page', 1, 20, '', self::STATUSES ) )->to_array(); + $post = $result['posts'][0]; + + // Status is empty because get_post() returned null. + $this->assertSame( '', $post['status'] ); + $this->assertSame( 7, $post['id'] ); + $this->assertTrue( $post['editable'] ); + } + /** * Stubs run_query so it returns a WP_Query with the given post IDs and total. * From 0be971529628355600673d6a95e78e1f4362f192 Mon Sep 17 00:00:00 2001 From: vraja-pro Date: Fri, 7 Aug 2026 17:23:06 +0300 Subject: [PATCH 23/34] tests(bulk-editor): tighten post-meta collector resolver assertions and add @covers Change allows() to expects() for the four template-resolver mocks in the two fallback tests, so a bug that stops the resolver from being called fails the test rather than passing silently. Add missing @covers for build_needs_improvement. Co-Authored-By: Claude Sonnet 4.6 --- .../Posts/Post_Meta_Posts_Collector/Get_Posts_Test.php | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/Unit/Bulk_Editor/Infrastructure/Posts/Post_Meta_Posts_Collector/Get_Posts_Test.php b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Post_Meta_Posts_Collector/Get_Posts_Test.php index 3fe482349a9..cc955f7c416 100644 --- a/tests/Unit/Bulk_Editor/Infrastructure/Posts/Post_Meta_Posts_Collector/Get_Posts_Test.php +++ b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Post_Meta_Posts_Collector/Get_Posts_Test.php @@ -15,6 +15,7 @@ * @group bulk-editor * * @covers Yoast\WP\SEO\Bulk_Editor\Infrastructure\Posts\Post_Meta_Posts_Collector::get_posts + * @covers Yoast\WP\SEO\Bulk_Editor\Infrastructure\Posts\Post_Meta_Posts_Collector::build_needs_improvement */ final class Get_Posts_Test extends Abstract_Post_Meta_Posts_Collector_Test { @@ -201,10 +202,10 @@ static function ( $post_id, $key ) use ( $meta ) { }, ); - $this->default_template_resolver->allows( 'resolve_seo_title' ) + $this->default_template_resolver->expects( 'resolve_seo_title' ) ->with( 7, 'page', '' ) ->andReturn( 'Page title from template' ); - $this->default_template_resolver->allows( 'resolve_meta_description' ) + $this->default_template_resolver->expects( 'resolve_meta_description' ) ->with( 7, 'page', '' ) ->andReturn( 'Page description from template' ); @@ -256,10 +257,10 @@ static function ( $post_id, $key ) use ( $meta ) { }, ); - $this->default_template_resolver->allows( 'resolve_social_title' ) + $this->default_template_resolver->expects( 'resolve_social_title' ) ->with( 7, 'post', '' ) ->andReturn( 'Social title from template' ); - $this->default_template_resolver->allows( 'resolve_social_description' ) + $this->default_template_resolver->expects( 'resolve_social_description' ) ->with( 7, 'post', '' ) ->andReturn( 'Social description from template' ); From 86123f9fd65915cbb776cd13ce3313ab565935e1 Mon Sep 17 00:00:00 2001 From: vraja-pro Date: Fri, 7 Aug 2026 17:29:43 +0300 Subject: [PATCH 24/34] tests: fix cs Co-Authored-By: Claude Sonnet 4.6 --- .../posts/indexable-posts-collector.php | 10 +++++----- .../posts/post-meta-posts-collector.php | 11 ++++++----- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/src/bulk-editor/infrastructure/posts/indexable-posts-collector.php b/src/bulk-editor/infrastructure/posts/indexable-posts-collector.php index 37050d5514a..22a34d45b23 100644 --- a/src/bulk-editor/infrastructure/posts/indexable-posts-collector.php +++ b/src/bulk-editor/infrastructure/posts/indexable-posts-collector.php @@ -226,7 +226,7 @@ private function apply_needs_improvement( ORM $builder, array $fields, bool $sco // Always add a clause per field — use a false condition when no real predicate applies so the // field still participates in the outer OR group without incorrectly matching every row. - $clauses[] = '( ' . ( $field_clauses !== [] ? \implode( ' OR ', $field_clauses ) : '1 = 0' ) . ' )'; + $clauses[] = '( ' . ( ( $field_clauses !== [] ) ? \implode( ' OR ', $field_clauses ) : '1 = 0' ) . ' )'; } if ( $clauses === [] ) { @@ -317,10 +317,10 @@ private function build_post( Indexable $indexable, bool $editable, bool $scores_ $raw_social_description, true, $this->build_needs_improvement( $indexable, $scores_enabled, $resolved_values ), - $raw_seo_title === '' ? $resolved_values['seo_title'] : '', - $raw_meta_description === '' ? $resolved_values['meta_description'] : '', - $raw_social_title === '' ? $resolved_values['social_title'] : '', - $raw_social_description === '' ? $resolved_values['social_description'] : '', + ( $raw_seo_title === '' ) ? $resolved_values['seo_title'] : '', + ( $raw_meta_description === '' ) ? $resolved_values['meta_description'] : '', + ( $raw_social_title === '' ) ? $resolved_values['social_title'] : '', + ( $raw_social_description === '' ) ? $resolved_values['social_description'] : '', ); } diff --git a/src/bulk-editor/infrastructure/posts/post-meta-posts-collector.php b/src/bulk-editor/infrastructure/posts/post-meta-posts-collector.php index 6557ce850cd..2398c56ffe2 100644 --- a/src/bulk-editor/infrastructure/posts/post-meta-posts-collector.php +++ b/src/bulk-editor/infrastructure/posts/post-meta-posts-collector.php @@ -260,10 +260,10 @@ private function build_post( int $post_id, bool $editable, bool $scores_enabled $raw_social_description, true, $this->build_needs_improvement( $post_id, $fields, $scores_enabled ), - $raw_seo_title === '' ? $fields['seo_title'] : '', - $raw_meta_description === '' ? $fields['meta_description'] : '', - $raw_social_title === '' ? $fields['social_title'] : '', - $raw_social_description === '' ? $fields['social_description'] : '', + ( $raw_seo_title === '' ) ? $fields['seo_title'] : '', + ( $raw_meta_description === '' ) ? $fields['meta_description'] : '', + ( $raw_social_title === '' ) ? $fields['social_title'] : '', + ( $raw_social_description === '' ) ? $fields['social_description'] : '', ); } @@ -309,6 +309,7 @@ private function build_needs_improvement( int $post_id, array $fields, bool $sco * * @param array $fields The fields that need improvement. * @param bool $scores_enabled Whether the per-field scores may back the filter. + * @param string $post_type The post type slug. * * @return string The prepared WHERE clause, or an empty string when no known field is selected. */ @@ -354,7 +355,7 @@ protected function build_needs_improvement_where( array $fields, bool $scores_en // Always add a clause per field — use a false condition when no real predicate applies so the // field still participates in the outer OR group without incorrectly matching every row. - $clauses[] = '( ' . ( $field_clauses !== [] ? \implode( ' OR ', $field_clauses ) : '0 = 1' ) . ' )'; + $clauses[] = '( ' . ( ( $field_clauses !== [] ) ? \implode( ' OR ', $field_clauses ) : '0 = 1' ) . ' )'; } if ( $clauses === [] ) { From 63c9d51dcc5b5f5eea6267e5b39ba8789f9d5b64 Mon Sep 17 00:00:00 2001 From: vraja-pro Date: Fri, 7 Aug 2026 17:44:22 +0300 Subject: [PATCH 25/34] fix(bulk-editor): return stored social title/description before OpenGraph gate, fix stale test expectations resolve_social_title() and resolve_social_description() were checking the OpenGraph option before returning a stored value, so a non-empty stored value was incorrectly gated behind the OpenGraph flag. Move the stored-value early return before the OG check so a post with an explicit social title/description always shows it regardless of the site-wide OpenGraph setting. Update five test assertions to include the four *_fallback keys that Post::to_array() now emits, and add the WP stubs required by WPSEO_Admin_Editor_Specific_Replace_Vars so the Bulk_Editor_Integration tests that exercise get_script_data() / enqueue_assets() no longer error. Co-Authored-By: Claude Sonnet 4.6 --- .../posts/default-template-resolver.php | 12 ++++- .../Domain/Posts/Posts_Page_Test.php | 26 +++++---- .../Get_Posts_Test.php | 52 ++++++++++-------- .../Build_Needs_Improvement_Where_Test.php | 3 +- .../Get_Posts_Test.php | 54 +++++++++++-------- .../Abstract_Bulk_Editor_Integration_Test.php | 18 +++++++ .../Enqueue_Assets_Test.php | 1 + .../Get_Initial_Selection_Test.php | 2 + 8 files changed, 109 insertions(+), 59 deletions(-) diff --git a/src/bulk-editor/infrastructure/posts/default-template-resolver.php b/src/bulk-editor/infrastructure/posts/default-template-resolver.php index 28ac1d35866..d55d1adf7ce 100644 --- a/src/bulk-editor/infrastructure/posts/default-template-resolver.php +++ b/src/bulk-editor/infrastructure/posts/default-template-resolver.php @@ -79,11 +79,15 @@ public function resolve_meta_description( int $post_id, string $post_type, strin * @return string The raw template string, or an empty string when no template is configured. */ public function resolve_social_title( int $post_id, string $post_type, string $stored_value ): string { + if ( $stored_value !== '' ) { + return $stored_value; + } + if ( $this->options_helper->get( 'opengraph', false ) !== true ) { return ''; } - return $this->resolve( $post_type, $stored_value, 'social-title-', true ); + return $this->resolve( $post_type, '', 'social-title-', true ); } /** @@ -99,11 +103,15 @@ public function resolve_social_title( int $post_id, string $post_type, string $s * @return string The raw template string, or an empty string when no template is configured. */ public function resolve_social_description( int $post_id, string $post_type, string $stored_value ): string { + if ( $stored_value !== '' ) { + return $stored_value; + } + if ( $this->options_helper->get( 'opengraph', false ) !== true ) { return ''; } - return $this->resolve( $post_type, $stored_value, 'social-description-', false ); + return $this->resolve( $post_type, '', 'social-description-', false ); } /** diff --git a/tests/Unit/Bulk_Editor/Domain/Posts/Posts_Page_Test.php b/tests/Unit/Bulk_Editor/Domain/Posts/Posts_Page_Test.php index a6ecc8b82b5..cb1f1e4f3a3 100644 --- a/tests/Unit/Bulk_Editor/Domain/Posts/Posts_Page_Test.php +++ b/tests/Unit/Bulk_Editor/Domain/Posts/Posts_Page_Test.php @@ -33,17 +33,21 @@ public function test_to_array() { [ 'posts' => [ [ - 'id' => 7, - 'title' => 'Hello world', - 'status' => 'draft', - 'edit_link' => 'edit', - 'focus_keyphrase' => 'hello', - 'seo_title' => 'SEO', - 'meta_description' => 'Meta', - 'social_title' => 'OG', - 'social_description' => 'OG desc', - 'editable' => true, - 'needs_improvement' => [ + 'id' => 7, + 'title' => 'Hello world', + 'status' => 'draft', + 'edit_link' => 'edit', + 'focus_keyphrase' => 'hello', + 'seo_title' => 'SEO', + 'meta_description' => 'Meta', + 'social_title' => 'OG', + 'social_description' => 'OG desc', + 'seo_title_fallback' => '', + 'meta_description_fallback' => '', + 'social_title_fallback' => '', + 'social_description_fallback' => '', + 'editable' => true, + 'needs_improvement' => [ 'seo_title' => false, 'meta_description' => false, 'social_title' => false, diff --git a/tests/Unit/Bulk_Editor/Infrastructure/Posts/Indexable_Posts_Collector/Get_Posts_Test.php b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Indexable_Posts_Collector/Get_Posts_Test.php index 7958872ad03..56b68958489 100644 --- a/tests/Unit/Bulk_Editor/Infrastructure/Posts/Indexable_Posts_Collector/Get_Posts_Test.php +++ b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Indexable_Posts_Collector/Get_Posts_Test.php @@ -63,17 +63,21 @@ public function test_get_posts_editable() { [ 'posts' => [ [ - 'id' => 7, - 'title' => 'Hello world', - 'status' => 'draft', - 'edit_link' => 'post.php?post=7&action=edit', - 'focus_keyphrase' => 'hello', - 'seo_title' => 'Hello | Site', - 'meta_description' => 'A description.', - 'social_title' => 'Social hello', - 'social_description' => 'Social description.', - 'editable' => true, - 'needs_improvement' => [ + 'id' => 7, + 'title' => 'Hello world', + 'status' => 'draft', + 'edit_link' => 'post.php?post=7&action=edit', + 'focus_keyphrase' => 'hello', + 'seo_title' => 'Hello | Site', + 'meta_description' => 'A description.', + 'social_title' => 'Social hello', + 'social_description' => 'Social description.', + 'seo_title_fallback' => '', + 'meta_description_fallback' => '', + 'social_title_fallback' => '', + 'social_description_fallback' => '', + 'editable' => true, + 'needs_improvement' => [ 'seo_title' => false, 'meta_description' => true, 'social_title' => false, @@ -205,17 +209,21 @@ public function test_get_posts_locks_non_editable_post() { $this->assertSame( [ - 'id' => 7, - 'title' => 'Secret post', - 'status' => 'publish', - 'edit_link' => '', - 'focus_keyphrase' => '', - 'seo_title' => '', - 'meta_description' => '', - 'social_title' => '', - 'social_description' => '', - 'editable' => false, - 'needs_improvement' => [ + 'id' => 7, + 'title' => 'Secret post', + 'status' => 'publish', + 'edit_link' => '', + 'focus_keyphrase' => '', + 'seo_title' => '', + 'meta_description' => '', + 'social_title' => '', + 'social_description' => '', + 'seo_title_fallback' => '', + 'meta_description_fallback' => '', + 'social_title_fallback' => '', + 'social_description_fallback' => '', + 'editable' => false, + 'needs_improvement' => [ 'seo_title' => false, 'meta_description' => false, 'social_title' => false, diff --git a/tests/Unit/Bulk_Editor/Infrastructure/Posts/Post_Meta_Posts_Collector/Build_Needs_Improvement_Where_Test.php b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Post_Meta_Posts_Collector/Build_Needs_Improvement_Where_Test.php index 4c3ac11ccd7..13647c24dbd 100644 --- a/tests/Unit/Bulk_Editor/Infrastructure/Posts/Post_Meta_Posts_Collector/Build_Needs_Improvement_Where_Test.php +++ b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Post_Meta_Posts_Collector/Build_Needs_Improvement_Where_Test.php @@ -5,6 +5,7 @@ namespace Yoast\WP\SEO\Tests\Unit\Bulk_Editor\Infrastructure\Posts\Post_Meta_Posts_Collector; use Mockery; +use Mockery\MockInterface; use Yoast\WP\SEO\Bulk_Editor\Infrastructure\Posts\Default_Template_Resolver; use Yoast\WP\SEO\Bulk_Editor\Infrastructure\Posts\Post_Editability_Resolver; use Yoast\WP\SEO\Tests\Unit\Doubles\Bulk_Editor\Post_Meta_Posts_Collector_Double; @@ -29,7 +30,7 @@ final class Build_Needs_Improvement_Where_Test extends TestCase { /** * The default template resolver mock. * - * @var Default_Template_Resolver&\Mockery\MockInterface + * @var Default_Template_Resolver&MockInterface */ private $default_template_resolver; diff --git a/tests/Unit/Bulk_Editor/Infrastructure/Posts/Post_Meta_Posts_Collector/Get_Posts_Test.php b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Post_Meta_Posts_Collector/Get_Posts_Test.php index cc955f7c416..9c7b38cdc5a 100644 --- a/tests/Unit/Bulk_Editor/Infrastructure/Posts/Post_Meta_Posts_Collector/Get_Posts_Test.php +++ b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Post_Meta_Posts_Collector/Get_Posts_Test.php @@ -66,17 +66,21 @@ static function ( $post_id, $key ) use ( $meta ) { [ 'posts' => [ [ - 'id' => 7, - 'title' => 'Hello world', - 'status' => 'draft', - 'edit_link' => 'post.php?post=7&action=edit', - 'focus_keyphrase' => 'hello', - 'seo_title' => 'Hello | Site', - 'meta_description' => 'A description.', - 'social_title' => 'Social hello', - 'social_description' => 'Social description.', - 'editable' => true, - 'needs_improvement' => [ + 'id' => 7, + 'title' => 'Hello world', + 'status' => 'draft', + 'edit_link' => 'post.php?post=7&action=edit', + 'focus_keyphrase' => 'hello', + 'seo_title' => 'Hello | Site', + 'meta_description' => 'A description.', + 'social_title' => 'Social hello', + 'social_description' => 'Social description.', + 'seo_title_fallback' => '', + 'meta_description_fallback' => '', + 'social_title_fallback' => '', + 'social_description_fallback' => '', + 'editable' => true, + 'needs_improvement' => [ 'seo_title' => false, 'meta_description' => true, 'social_title' => false, @@ -118,17 +122,21 @@ public function test_get_posts_locks_non_editable_post() { $this->assertSame( [ - 'id' => 7, - 'title' => 'Secret post', - 'status' => 'publish', - 'edit_link' => '', - 'focus_keyphrase' => '', - 'seo_title' => '', - 'meta_description' => '', - 'social_title' => '', - 'social_description' => '', - 'editable' => false, - 'needs_improvement' => [ + 'id' => 7, + 'title' => 'Secret post', + 'status' => 'publish', + 'edit_link' => '', + 'focus_keyphrase' => '', + 'seo_title' => '', + 'meta_description' => '', + 'social_title' => '', + 'social_description' => '', + 'seo_title_fallback' => '', + 'meta_description_fallback' => '', + 'social_title_fallback' => '', + 'social_description_fallback' => '', + 'editable' => false, + 'needs_improvement' => [ 'seo_title' => false, 'meta_description' => false, 'social_title' => false, @@ -278,7 +286,7 @@ static function ( $post_id, $key ) use ( $meta ) { /** * Tests that the collector handles a null get_post() return gracefully when the post is editable. * - * get_post() returns null when the post has been deleted between the WP_Query and the per-row fetch. + * The get_post() function returns null when the post has been deleted between the WP_Query and the per-row fetch. * In that case post_type falls back to '' and the resolver is called with an empty post type; the * collector must not crash and must return a post with empty status. * diff --git a/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Abstract_Bulk_Editor_Integration_Test.php b/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Abstract_Bulk_Editor_Integration_Test.php index 35279550bad..66affe00b95 100644 --- a/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Abstract_Bulk_Editor_Integration_Test.php +++ b/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Abstract_Bulk_Editor_Integration_Test.php @@ -4,6 +4,7 @@ // phpcs:disable Yoast.NamingConventions.NamespaceName.MaxExceeded namespace Yoast\WP\SEO\Tests\Unit\Bulk_Editor\User_Interface\Bulk_Editor_Integration; +use Brain\Monkey\Functions; use Mockery; use WPSEO_Admin_Asset_Manager; use WPSEO_Replace_Vars; @@ -102,6 +103,23 @@ abstract class Abstract_Bulk_Editor_Integration_Test extends TestCase { */ protected $replace_vars; + /** + * Stubs the WP globals and functions consumed by WPSEO_Admin_Editor_Specific_Replace_Vars::__construct(). + * + * Must be called before any test that exercises get_script_data() / enqueue_assets(). + * + * @return void + */ + protected function stub_wpseo_admin_replace_vars_dependencies(): void { + global $wpdb; + $wpdb = Mockery::mock(); + $wpdb->postmeta = 'wp_postmeta'; + $wpdb->allows( 'prepare' )->andReturn( '' ); + $wpdb->allows( 'get_col' )->andReturn( [] ); + + Functions\stubs( [ 'get_taxonomies' => [] ] ); + } + /** * Sets up the test fixtures. * diff --git a/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Enqueue_Assets_Test.php b/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Enqueue_Assets_Test.php index 976ed0dbbb5..78af01ece58 100644 --- a/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Enqueue_Assets_Test.php +++ b/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Enqueue_Assets_Test.php @@ -26,6 +26,7 @@ final class Enqueue_Assets_Test extends Abstract_Bulk_Editor_Integration_Test { * @return void */ public function test_enqueue_assets() { + $this->stub_wpseo_admin_replace_vars_dependencies(); $this->stubEscapeFunctions(); $content_types = [ diff --git a/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Get_Initial_Selection_Test.php b/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Get_Initial_Selection_Test.php index 7389b4ef05d..82a435298fa 100644 --- a/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Get_Initial_Selection_Test.php +++ b/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Get_Initial_Selection_Test.php @@ -172,6 +172,8 @@ public function test_ignores_a_non_scalar_selected_count() { * @return array> The initial selection script data. */ private function get_initial_selection() { + $this->stub_wpseo_admin_replace_vars_dependencies(); + $this->replace_vars->allows( 'get_replacement_variables_with_labels' )->andReturn( [] ); $this->stubEscapeFunctions(); Functions\stubs( [ From 280a131b4263cdf84237fb0a3418f95931209e4d Mon Sep 17 00:00:00 2001 From: Vraja Das Date: Mon, 10 Aug 2026 10:46:16 +0300 Subject: [PATCH 26/34] fix(bulk-editor): complexity and cleanup of table cell components * Reduced complexity of table row component by creating preview editable field cell component. * Removed unused table helper, there is now deparation between focus keyphrase fields and the rest of the fields. --- .../table/preview-editable-field-cell.js | 42 +++++++++++++++++++ .../components/table/table-helpers.js | 17 -------- .../bulk-editor/components/table/table-row.js | 35 ++++------------ 3 files changed, 50 insertions(+), 44 deletions(-) create mode 100644 packages/js/src/bulk-editor/components/table/preview-editable-field-cell.js diff --git a/packages/js/src/bulk-editor/components/table/preview-editable-field-cell.js b/packages/js/src/bulk-editor/components/table/preview-editable-field-cell.js new file mode 100644 index 00000000000..b33c9f06d8a --- /dev/null +++ b/packages/js/src/bulk-editor/components/table/preview-editable-field-cell.js @@ -0,0 +1,42 @@ +import { Table } from "@yoast/ui-library"; +import { ReplacementVariableEditorStandalone } from "@yoast/replacement-variable-editor"; +import { noop } from "lodash"; +import { sprintf, __ } from "@wordpress/i18n"; + +/** + * + * @param {object} props The component props. + * @param {object} props.field The field to render. + * @param {object} props.item The content item to render. + * @param {Array} props.replacementVariables The replacement variables for this content type. + * @param {Array} props.recommendedReplacementVariables The recommended replacement variables for this content type. + * @returns {JSX.Element} The cell. + */ +export const PreviewEditableFieldCell = ( { field, item, replacementVariables, recommendedReplacementVariables } ) => { + if ( field.type ) { + return ( + + + { sprintf( + /* translators: %1$s expands to the field label, %2$s to the content item title. */ + __( "%1$s for %2$s", "wordpress-seo" ), field.label, item.title ) } + + + + ); + } + return ( + + { item[ field.key ] } + + ); +}; diff --git a/packages/js/src/bulk-editor/components/table/table-helpers.js b/packages/js/src/bulk-editor/components/table/table-helpers.js index b9ee0acbe8c..ce754cdb4ae 100644 --- a/packages/js/src/bulk-editor/components/table/table-helpers.js +++ b/packages/js/src/bulk-editor/components/table/table-helpers.js @@ -1,21 +1,4 @@ import { __ } from "@wordpress/i18n"; -import { FOCUS_KEYPHRASE_KEY } from "../../constants"; - -/** - * The text classes for a field's value, by column and edit state. - * - * @param {string} fieldKey The field key. - * @param {boolean} isEditing Whether the field is being edited. - * - * @returns {string} The text size and color classes. - */ -export const getFieldTextClasses = ( fieldKey, isEditing ) => { - if ( fieldKey === FOCUS_KEYPHRASE_KEY ) { - return "!yst-text-[13px] !yst-text-slate-800"; - } - - return isEditing ? "!yst-text-[13px] !yst-text-slate-600" : "!yst-text-[13px] !yst-text-slate-800"; -}; /** * Maps a post status to a label, or "" for published (no label shown). diff --git a/packages/js/src/bulk-editor/components/table/table-row.js b/packages/js/src/bulk-editor/components/table/table-row.js index f70e8c424aa..22ecaa1776d 100644 --- a/packages/js/src/bulk-editor/components/table/table-row.js +++ b/packages/js/src/bulk-editor/components/table/table-row.js @@ -2,12 +2,11 @@ import { Slot, __experimentalUseSlotFills as useSlotFills } from "@wordpress/com import { Fragment, useCallback } from "@wordpress/element"; import { useSelect } from "@wordpress/data"; import { __, sprintf } from "@wordpress/i18n"; -import { ReplacementVariableEditorStandalone } from "@yoast/replacement-variable-editor"; import { Button, Checkbox, Table } from "@yoast/ui-library"; -import { noop } from "lodash"; import { STORE_NAME, TABLE_CELL_FIELD_SLOT } from "../../constants"; import { EditableFieldCell, TitleCell } from "./table-cells"; -import { getFieldTextClasses, getRowEditState, isRowEditDisabled } from "./table-helpers"; +import { getRowEditState, isRowEditDisabled } from "./table-helpers"; +import { PreviewEditableFieldCell } from "./preview-editable-field-cell"; /** * A content row. Each field-set cell renders as plain text, or — when the row is in edit mode and the field is @@ -112,31 +111,13 @@ export const BulkEditorRow = ( { } if ( ! openFields.includes( field.key ) ) { - if ( field.type ) { - return ( - - - { sprintf( - /* translators: %1$s expands to the field label, %2$s to the content item title. */ - __( "%1$s for %2$s", "wordpress-seo" ), field.label, item.title ) } - - - - ); - } return ( - - { item[ field.key ] } - + ); } From d3bba773ad89aa17dd0e61973e29646bcfc1ec25 Mon Sep 17 00:00:00 2001 From: Vraja Das Date: Mon, 10 Aug 2026 11:14:40 +0300 Subject: [PATCH 27/34] tests(bulk editor): fix my yoast connection tests --- .../Myyoast_Connection_Data_Test.php | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/WP/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Myyoast_Connection_Data_Test.php b/tests/WP/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Myyoast_Connection_Data_Test.php index bcd651c7033..d9c38d8b081 100644 --- a/tests/WP/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Myyoast_Connection_Data_Test.php +++ b/tests/WP/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Myyoast_Connection_Data_Test.php @@ -6,6 +6,7 @@ use Mockery; use WPSEO_Admin_Asset_Manager; +use WPSEO_Replace_Vars; use Yoast\WP\SEO\Bulk_Editor\Application\Content_Types\Content_Types_Repository; use Yoast\WP\SEO\Bulk_Editor\Application\Endpoints\Endpoints_Repository; use Yoast\WP\SEO\Bulk_Editor\Infrastructure\Nonces\Nonce_Repository; @@ -64,6 +65,13 @@ final class Myyoast_Connection_Data_Test extends TestCase { */ private $connection_permission; + /** + * The replacement-variable helper mock. + * + * @var Mockery\MockInterface|WPSEO_Replace_Vars + */ + private $replace_vars; + /** * Sets up the test fixtures. * @@ -79,6 +87,7 @@ public function set_up() { $this->myyoast_connection_conditional = Mockery::mock( MyYoast_Connection_Conditional::class ); $this->status_presenter = Mockery::mock( Status_Presenter::class ); $this->connection_permission = Mockery::mock( Connection_Permission::class ); + $this->replace_vars = Mockery::mock( WPSEO_Replace_Vars::class ); $endpoint_list = Mockery::mock( Endpoint_List::class ); $endpoint_list->allows( 'to_array' )->andReturn( [] ); @@ -119,6 +128,7 @@ public function set_up() { $endpoints_repository, $options_helper, $myyoast_connection_data_presenter, + $this->replace_vars, ); } @@ -129,6 +139,7 @@ public function set_up() { */ public function test_myyoast_connection_is_null_when_feature_flag_is_disabled() { $this->myyoast_connection_conditional->expects( 'is_met' )->once()->andReturn( false ); + $this->replace_vars->expects( 'get_replacement_variables_with_labels' )->andReturn( [] ); $data = $this->instance->get_script_data(); @@ -147,6 +158,7 @@ public function test_myyoast_connection_when_provisioned_and_can_connect() { $this->myyoast_connection_conditional->expects( 'is_met' )->once()->andReturn( true ); $this->status_presenter->expects( 'present' )->once()->andReturn( [ 'is_provisioned' => true ] ); $this->connection_permission->expects( 'can_manage' )->once()->andReturn( true ); + $this->replace_vars->expects( 'get_replacement_variables_with_labels' )->andReturn( [] ); $data = $this->instance->get_script_data(); $connection = $data['myyoastConnection']; @@ -167,6 +179,7 @@ public function test_myyoast_connection_when_not_provisioned_and_cannot_connect( $this->myyoast_connection_conditional->expects( 'is_met' )->once()->andReturn( true ); $this->status_presenter->expects( 'present' )->once()->andReturn( [ 'is_provisioned' => false ] ); $this->connection_permission->expects( 'can_manage' )->once()->andReturn( false ); + $this->replace_vars->expects( 'get_replacement_variables_with_labels' )->andReturn( [] ); $data = $this->instance->get_script_data(); $connection = $data['myyoastConnection']; @@ -185,6 +198,7 @@ public function test_is_provisioned_is_false_when_not_a_boolean() { $this->myyoast_connection_conditional->expects( 'is_met' )->once()->andReturn( true ); $this->status_presenter->expects( 'present' )->once()->andReturn( [ 'is_provisioned' => 1 ] ); $this->connection_permission->expects( 'can_manage' )->once()->andReturn( false ); + $this->replace_vars->expects( 'get_replacement_variables_with_labels' )->andReturn( [] ); $data = $this->instance->get_script_data(); From 95442ebab2eabc7957d396c4a25f049fd250b4e0 Mon Sep 17 00:00:00 2001 From: Vraja Das Date: Mon, 10 Aug 2026 15:13:32 +0300 Subject: [PATCH 28/34] test: fix test after feature branch merge --- packages/js/tests/bulk-editor/initialize.test.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/js/tests/bulk-editor/initialize.test.js b/packages/js/tests/bulk-editor/initialize.test.js index 727c111e74b..bf42dfc44d3 100644 --- a/packages/js/tests/bulk-editor/initialize.test.js +++ b/packages/js/tests/bulk-editor/initialize.test.js @@ -106,6 +106,10 @@ describe( "bulk editor initialize", () => { optInNotification: { seen: { [ TOUR_OPT_IN_KEY ]: true } }, activeContentType: "", selection: { selectedIds: [], preselectedTotal: 0 }, + query: { + isOverviewFilterActive: false, + overviewIds: [], + }, }, } ); expect( mockFixScrolling ).toHaveBeenCalledTimes( 1 ); From 2ded23b2434884a1bc1507a07069ca902d0ab477 Mon Sep 17 00:00:00 2001 From: Vraja Das Date: Mon, 10 Aug 2026 15:26:10 +0300 Subject: [PATCH 29/34] tests: fix tests for bulk editor --- .../Bulk_Editor_Integration/Enqueue_Assets_Test.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Enqueue_Assets_Test.php b/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Enqueue_Assets_Test.php index cd8ef4d4b00..9c64e6c2515 100644 --- a/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Enqueue_Assets_Test.php +++ b/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Enqueue_Assets_Test.php @@ -73,6 +73,7 @@ static function ( $path ) { ->once() ->with( 1, '_yoast_wpseo_bulk_editor_tour_opt_in_notification_seen', true ) ->andReturn( '' ); + $this->replace_vars->expects( 'get_replacement_variables_with_labels' )->once()->andReturn( [] ); $this->asset_manager->expects( 'localize_script' ) ->once() @@ -82,7 +83,7 @@ static function ( $path ) { Mockery::on( static function ( $data ) use ( $content_types ) { return $data['contentTypes'] === $content_types - && $data['nonce'] === 'rest-nonce' + && $data['preferences']['nonce'] === 'rest-nonce' && $data['preferences']['isPremium'] === false && \array_key_exists( 'replacementVariables', $data ) && \array_key_exists( 'variables', $data['replacementVariables'] ) From 714f7625337bd6076f6df2f959d49afc78cc87e8 Mon Sep 17 00:00:00 2001 From: Vraja Das Date: Tue, 11 Aug 2026 17:42:11 +0300 Subject: [PATCH 30/34] fix: tests for bulk editor integration --- src/bulk-editor/user-interface/bulk-editor-integration.php | 1 + .../Bulk_Editor_Integration/Enqueue_Assets_Test.php | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/bulk-editor/user-interface/bulk-editor-integration.php b/src/bulk-editor/user-interface/bulk-editor-integration.php index d1cd3a12642..94a0e3248f5 100644 --- a/src/bulk-editor/user-interface/bulk-editor-integration.php +++ b/src/bulk-editor/user-interface/bulk-editor-integration.php @@ -274,6 +274,7 @@ public function get_script_data() { 'tools' => \admin_url( 'admin.php?page=wpseo_tools' ), ], 'nonce' => $this->nonce_repository->get_rest_nonce(), + 'restRoot' => \esc_url_raw( \rest_url() ), 'preferences' => [ 'isPremium' => $this->product_helper->is_premium(), 'isAiEnabled' => $this->options_helper->get( 'enable_ai_generator' ) === true, diff --git a/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Enqueue_Assets_Test.php b/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Enqueue_Assets_Test.php index 9c64e6c2515..0526717796f 100644 --- a/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Enqueue_Assets_Test.php +++ b/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Enqueue_Assets_Test.php @@ -83,7 +83,7 @@ static function ( $path ) { Mockery::on( static function ( $data ) use ( $content_types ) { return $data['contentTypes'] === $content_types - && $data['preferences']['nonce'] === 'rest-nonce' + && $data['nonce'] === 'rest-nonce' && $data['preferences']['isPremium'] === false && \array_key_exists( 'replacementVariables', $data ) && \array_key_exists( 'variables', $data['replacementVariables'] ) From d3ecffef85fc4c36d86b6cf998d5dccb34f79163 Mon Sep 17 00:00:00 2001 From: Vraja Das Date: Tue, 11 Aug 2026 17:54:50 +0300 Subject: [PATCH 31/34] fix: fix php cs --- src/bulk-editor/user-interface/bulk-editor-integration.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bulk-editor/user-interface/bulk-editor-integration.php b/src/bulk-editor/user-interface/bulk-editor-integration.php index 94a0e3248f5..24784ebd8d9 100644 --- a/src/bulk-editor/user-interface/bulk-editor-integration.php +++ b/src/bulk-editor/user-interface/bulk-editor-integration.php @@ -274,7 +274,7 @@ public function get_script_data() { 'tools' => \admin_url( 'admin.php?page=wpseo_tools' ), ], 'nonce' => $this->nonce_repository->get_rest_nonce(), - 'restRoot' => \esc_url_raw( \rest_url() ), + 'restRoot' => \esc_url_raw( \rest_url() ), 'preferences' => [ 'isPremium' => $this->product_helper->is_premium(), 'isAiEnabled' => $this->options_helper->get( 'enable_ai_generator' ) === true, From 65e4199510237efce77147658a2c69299316b766 Mon Sep 17 00:00:00 2001 From: vraja-pro Date: Wed, 12 Aug 2026 18:13:14 +0300 Subject: [PATCH 32/34] fix(bulk-editor): show fallback template as pills when opening edit for posts with no stored value Seeds the inline-edit draft with the post type's fallback template when no stored value exists, so the ReplacementVariableEditor renders styled pills (matching the read-only preview) rather than opening blank. All three save paths normalize the value back to empty before posting when the draft was never changed, preserving the Search Appearance connection. Co-Authored-By: Claude Sonnet 4.6 --- .../src/bulk-editor/hooks/use-inline-edit.js | 37 ++++++++++++++----- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/packages/js/src/bulk-editor/hooks/use-inline-edit.js b/packages/js/src/bulk-editor/hooks/use-inline-edit.js index 912bc5b1c69..73eae4176ce 100644 --- a/packages/js/src/bulk-editor/hooks/use-inline-edit.js +++ b/packages/js/src/bulk-editor/hooks/use-inline-edit.js @@ -41,6 +41,20 @@ const resolveItemValue = ( key, draftValue, sanitized ) => { */ const getFirstSanitized = ( response ) => response?.results?.[ 0 ]?.sanitized; +/** + * Returns the draft value to persist, stripping it back to empty when it still equals the item's + * fallback template. This prevents clicking Save on an unedited row from baking the fallback template + * in as an explicit stored value, which would disconnect the post from Search Appearance. + * + * @param {string} value The current draft value. + * @param {Object|undefined} item The source item (may be undefined if the row was not found). + * @param {string} fieldKey The JS camelCase field key (e.g. "seoTitle"). + * + * @returns {string} The value to send to the server. + */ +const normalizeDraftValue = ( value, item, fieldKey ) => + value === ( item?.[ `${ fieldKey }Fallback` ] ?? "" ) ? "" : value; + /** * Re-scores a saved row from an update result, when it carries rendered search fields. * @@ -187,7 +201,7 @@ export const useInlineEdit = ( { dataProvider, remoteDataProvider, fieldSets, ac return; } const draftValues = Object.fromEntries( - fieldSets[ activeFieldSet ].fields.map( ( field ) => [ field.key, item[ field.key ] ?? "" ] ) + fieldSets[ activeFieldSet ].fields.map( ( field ) => [ field.key, item[ field.key ] || item[ `${ field.key }Fallback` ] || "" ] ) ); startEdit( { id, draft: draftValues } ); }, [ items, fieldSets, activeFieldSet, startEdit ] ); @@ -206,7 +220,8 @@ export const useInlineEdit = ( { dataProvider, remoteDataProvider, fieldSets, ac return; } - const value = rowEdit.draft[ key ]; + const rowItem = items.find( ( candidate ) => candidate.id === id ); + const value = normalizeDraftValue( rowEdit.draft[ key ], rowItem, key ); setSavingField( { id, key, isSaving: true } ); try { const response = await remoteDataProvider.fetchJson( endpoint, {}, { @@ -220,7 +235,7 @@ export const useInlineEdit = ( { dataProvider, remoteDataProvider, fieldSets, ac setSavingField( { id, key, isSaving: false } ); setHasSaveError( true ); } - }, [ fieldSets, activeFieldSet, dataProvider, remoteDataProvider, editingRows, setSavingField, closeField, updateItem, scoreFields ] ); + }, [ fieldSets, activeFieldSet, dataProvider, remoteDataProvider, editingRows, items, setSavingField, closeField, updateItem, scoreFields ] ); // Saves all open fields of a single row in as few requests as possible — one POST per endpoint, all fields // merged into one item. Called by the per-row Save button; re-scores once all succeed. @@ -232,6 +247,7 @@ export const useInlineEdit = ( { dataProvider, remoteDataProvider, fieldSets, ac } // Group the row's open fields by endpoint — one item per endpoint, all fields merged in. + const rowItem = items.find( ( candidate ) => candidate.id === id ); const batches = {}; rowEdit.openFields.forEach( ( key ) => { const field = fieldSet.fields.find( ( candidate ) => candidate.key === key ); @@ -246,8 +262,9 @@ export const useInlineEdit = ( { dataProvider, remoteDataProvider, fieldSets, ac if ( ! batches[ endpointKey ] ) { batches[ endpointKey ] = { endpoint, item: { id }, applied: [] }; } - batches[ endpointKey ].item[ field.param ] = rowEdit.draft[ key ]; - batches[ endpointKey ].applied.push( { key, value: rowEdit.draft[ key ] } ); + const value = normalizeDraftValue( rowEdit.draft[ key ], rowItem, key ); + batches[ endpointKey ].item[ field.param ] = value; + batches[ endpointKey ].applied.push( { key, value } ); } ); const groups = Object.values( batches ); @@ -283,7 +300,7 @@ export const useInlineEdit = ( { dataProvider, remoteDataProvider, fieldSets, ac if ( hasFailure ) { setHasSaveError( true ); } - }, [ fieldSets, activeFieldSet, dataProvider, remoteDataProvider, editingRows, setSavingField, updateItem, closeField, scoreFields ] ); + }, [ fieldSets, activeFieldSet, dataProvider, remoteDataProvider, editingRows, items, setSavingField, updateItem, closeField, scoreFields ] ); // Saves every open edit as one batch. Returns true (clean), false (a request failed), or null (a save was // already in flight), so the tab-switch modal only closes on a real failure and not on a re-entrant call. @@ -317,8 +334,10 @@ export const useInlineEdit = ( { dataProvider, remoteDataProvider, fieldSets, ac if ( ! batches[ endpointKey ].rows[ id ] ) { batches[ endpointKey ].rows[ id ] = { item: { id }, applied: [] }; } - batches[ endpointKey ].rows[ id ].item[ field.param ] = row.draft[ key ]; - batches[ endpointKey ].rows[ id ].applied.push( { id, key, value: row.draft[ key ] } ); + const rowItem = items.find( ( candidate ) => candidate.id === id ); + const value = normalizeDraftValue( row.draft[ key ], rowItem, key ); + batches[ endpointKey ].rows[ id ].item[ field.param ] = value; + batches[ endpointKey ].rows[ id ].applied.push( { id, key, value } ); } ); } ); @@ -374,7 +393,7 @@ export const useInlineEdit = ( { dataProvider, remoteDataProvider, fieldSets, ac isApplyingAllRef.current = false; setIsApplyingAll( false ); } - }, [ fieldSets, activeFieldSet, dataProvider, remoteDataProvider, editingRows, updateItem, closeField, scoreFields ] ); + }, [ fieldSets, activeFieldSet, dataProvider, remoteDataProvider, editingRows, items, updateItem, closeField, scoreFields ] ); const editing = useMemo( () => ( { editingRows, From 0695fc47dda7f0f5773c2aef15b7920c86797209 Mon Sep 17 00:00:00 2001 From: vraja-pro Date: Wed, 12 Aug 2026 18:28:20 +0300 Subject: [PATCH 33/34] fix(bulk-editor): make ReplacementVariableEditor the default cell branch, gate on focus keyphrase instead of field.type Fixes the missing return in EditableFieldCell (a field that is neither typed nor the focus keyphrase returned undefined, silently dropping a Table.Cell and shifting all subsequent columns left). Inverts the branch order so the focus keyphrase is the early-exit and the ReplacementVariableEditor is the default return. Applies the same inversion to PreviewEditableFieldCell for consistency. Co-Authored-By: Claude Sonnet 4.6 --- .../table/preview-editable-field-cell.js | 36 ++++++++++--------- .../components/table/table-cells.js | 36 +++++++++---------- 2 files changed, 35 insertions(+), 37 deletions(-) diff --git a/packages/js/src/bulk-editor/components/table/preview-editable-field-cell.js b/packages/js/src/bulk-editor/components/table/preview-editable-field-cell.js index b33c9f06d8a..c74e1062cb6 100644 --- a/packages/js/src/bulk-editor/components/table/preview-editable-field-cell.js +++ b/packages/js/src/bulk-editor/components/table/preview-editable-field-cell.js @@ -2,6 +2,7 @@ import { Table } from "@yoast/ui-library"; import { ReplacementVariableEditorStandalone } from "@yoast/replacement-variable-editor"; import { noop } from "lodash"; import { sprintf, __ } from "@wordpress/i18n"; +import { FOCUS_KEYPHRASE_KEY } from "../../constants"; /** * @@ -13,30 +14,31 @@ import { sprintf, __ } from "@wordpress/i18n"; * @returns {JSX.Element} The cell. */ export const PreviewEditableFieldCell = ( { field, item, replacementVariables, recommendedReplacementVariables } ) => { - if ( field.type ) { + if ( field.key === FOCUS_KEYPHRASE_KEY ) { return ( - - { sprintf( - /* translators: %1$s expands to the field label, %2$s to the content item title. */ - __( "%1$s for %2$s", "wordpress-seo" ), field.label, item.title ) } - - + { item[ field.key ] } ); } + return ( - { item[ field.key ] } + + { sprintf( + /* translators: %1$s expands to the field label, %2$s to the content item title. */ + __( "%1$s for %2$s", "wordpress-seo" ), field.label, item.title ) } + + ); }; diff --git a/packages/js/src/bulk-editor/components/table/table-cells.js b/packages/js/src/bulk-editor/components/table/table-cells.js index 79d322af2a0..a01e2a585a0 100644 --- a/packages/js/src/bulk-editor/components/table/table-cells.js +++ b/packages/js/src/bulk-editor/components/table/table-cells.js @@ -99,26 +99,6 @@ export const EditableFieldCell = ( { /* translators: %1$s expands to the field label, %2$s to the content item title. */ __( "%1$s for %2$s", "wordpress-seo" ), field.label, itemTitle ); - if ( field.type ) { - return ( - - - - - - ); - } - if ( field.key === FOCUS_KEYPHRASE_KEY ) { return ( @@ -135,4 +115,20 @@ export const EditableFieldCell = ( { ); } + + return ( + + + + ); }; From 0fd7bfbd47262b90bace9b9022efdbd8e53443e3 Mon Sep 17 00:00:00 2001 From: vraja-pro Date: Thu, 13 Aug 2026 14:32:09 +0300 Subject: [PATCH 34/34] fix(bulk-editor): seed store with first content type so replacement variable lookup works on initial load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The store initialises activeContentType as "" (a sentinel meaning "use the first available content type"), but store selectors that use it as a lookup key cannot resolve that sentinel — causing the replacement variable list to fall through to the custom_post_type bucket instead of the correct one. Resolve "" to the actual first content type name at store registration time, where the content types list from window data is already available. Co-Authored-By: Claude Sonnet 4.6 --- packages/js/src/bulk-editor/initialize.js | 8 ++++++-- packages/js/tests/bulk-editor/initialize.test.js | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/js/src/bulk-editor/initialize.js b/packages/js/src/bulk-editor/initialize.js index 014cd31c198..b7f8ab0a02a 100644 --- a/packages/js/src/bulk-editor/initialize.js +++ b/packages/js/src/bulk-editor/initialize.js @@ -63,7 +63,6 @@ export const getPreselectionState = ( initialSelection = {} ) => { .slice( 0, BULK_UPDATE_BATCH_SIZE ); return { - // An empty or unknown name resolves to the first available content type in the app. activeContentType: typeof initialSelection.contentType === "string" ? initialSelection.contentType : "", selection: { selectedIds, @@ -84,12 +83,17 @@ domReady( () => { // Null when the MyYoast connection feature is unavailable (flag off / not provisioned). const myyoastConnection = get( window, "wpseoBulkEditorData.myyoastConnection", null ); const replacementVariables = get( window, "wpseoBulkEditorData.replacementVariables", {} ); + const contentTypes = get( window, "wpseoBulkEditorData.contentTypes", [] ); + const preselectionState = getPreselectionState( get( window, "wpseoBulkEditorData.initialSelection", {} ) ); registerStore( { initialState: { [ LINK_PARAMS_NAME ]: get( window, "wpseoBulkEditorData.linkParams", {} ), [ MYYOAST_CONNECTION_NAME ]: getMyyoastConnectionState( myyoastConnection ), [ REPLACEMENT_VARIABLES_NAME ]: getReplacementVariablesInitialState( replacementVariables ), - ...getPreselectionState( get( window, "wpseoBulkEditorData.initialSelection", {} ) ), + ...preselectionState, + // Resolve "" (the "first available" sentinel) to the actual first content type name so that store + // selectors using it as a lookup key (e.g. replacement variables) work correctly on initial load. + activeContentType: preselectionState.activeContentType || contentTypes[ 0 ]?.name || "", [ OPT_IN_NOTIFICATION_NAME ]: { seen: get( window, "wpseoBulkEditorData.optInNotificationSeen", {} ), }, diff --git a/packages/js/tests/bulk-editor/initialize.test.js b/packages/js/tests/bulk-editor/initialize.test.js index 1a0a538c094..e88239f66c1 100644 --- a/packages/js/tests/bulk-editor/initialize.test.js +++ b/packages/js/tests/bulk-editor/initialize.test.js @@ -119,7 +119,7 @@ describe( "bulk editor initialize", () => { variables: [], }, optInNotification: { seen: { [ TOUR_OPT_IN_KEY ]: true } }, - activeContentType: "", + activeContentType: "post", selection: { selectedIds: [], preselectedTotal: 0 }, query: { isOverviewFilterActive: false,