Skip to content

fix(bulk-editor): resolve post type default template when SEO title/description is empty - #23538

Merged
FAMarfuaty merged 38 commits into
feature/bulk-editor-ph2from
1370-bulk-editor-seo-titlemeta-description-show-empty-instead-of-the-resolved-replacement-variable-template
Aug 13, 2026
Merged

fix(bulk-editor): resolve post type default template when SEO title/description is empty#23538
FAMarfuaty merged 38 commits into
feature/bulk-editor-ph2from
1370-bulk-editor-seo-titlemeta-description-show-empty-instead-of-the-resolved-replacement-variable-template

Conversation

@vraja-pro

@vraja-pro vraja-pro commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Context

When a post has never had its SEO title, meta description, social title, or social description explicitly saved, the bulk editor's GET /bulk_editor/posts path was returning empty strings for those fields — so the columns appeared blank even though Search Appearance had a configured default template.

Adds Default_Template_Resolver, a shared infrastructure helper that reads the post type's configured template from options (title-{post_type}, metadesc-{post_type}, social-title-{post_type}, social-description-{post_type}) and returns it as a raw template string (e.g. %%title%% %%sep%% %%sitename%%). The raw string is passed to the ReplacementVariableEditor so it is displayed as styled variable pills rather than resolved text. Both Indexable_Posts_Collector and Post_Meta_Posts_Collector now inject and call the resolver to produce a *_fallback sibling for each editable field.

Summary

This PR can be summarized in the following changelog entry:

  • Fixes a bug where the bulk editor showed empty SEO title, meta description, social title, and social description columns for posts whose values were never explicitly saved, even when the post type had a configured default template.

Relevant technical choices:

  • Shared Default_Template_Resolver class. The fallback logic is extracted into a dedicated infrastructure class rather than duplicated across the two collectors. A private resolve() helper covers all four fields; social fields additionally guard on the opengraph option.
  • Fallback chain mirrors the presentation layer. For SEO title the priority is: explicit stored value → user-configured post type template (title-{post_type}) → installation default. Meta description stops at the user-configured template. Social title follows the same chain using social-title-{post_type} (installation default %%title%%); social description mirrors meta description using social-description-{post_type}.
  • Stored value and display fallback are kept separate. The API response includes both seo_title (the raw stored value, empty when never saved) and seo_title_fallback (the post type template, empty when a value is already stored). The read-only column display shows stored || fallback; the draft is initialised with only the raw stored value. This means opening a row and saving without editing is a no-op — the post stays connected to Search Appearance instead of getting the template baked in as an explicit value.
  • needs_improvement verdict uses the resolved value. The emptiness check in build_needs_improvement() still operates on the resolver result rather than the raw stored value, so posts with a valid default template are not incorrectly flagged.
  • Moved the replacement variable redux slice to the shared admin folder and reused in bulk editor.

Test instructions

Test instructions for the acceptance test before the PR gets merged

This PR can be acceptance tested by following these steps:

Setup

  1. Go to SEO > Settings > Search Appearance and confirm the SEO title template for Posts (or the post type you will test) is set — e.g. %%title%% %%sep%% %%sitename%%. This is the default and is usually already configured.
  2. While still in Search Appearance, open the Social tab and note the social title template for that post type (the default is %%title%%).
  3. Create a brand-new post and publish it without opening the Yoast metabox or saving any SEO title, meta description, social title, or social description explicitly.

Verify the Search appearance tab
4. Go to SEO > Bulk Editor and open the Search appearance tab.
5. Find the post from step 3 in the list.
6. The SEO title column should show the post type's SEO title template as styled replacement variable pills (e.g. three pills: Title, Separator, Site title) instead of being empty.
7. If a meta description template is configured for that post type, the Meta description column should similarly show the template as pills.

Verify the Social appearance tab
8. Switch to the Social appearance tab.
9. The Social title column should show the social title template as a pill (e.g. a single Title pill from %%title%%) instead of being empty.
10. If a social description template is configured, the Social description column should show it as pills.

Verify the edit textarea and save behaviour
11. Edit SEO title or Meta description. The textarea field should have the replacement variables pills.
12. Without typing anything, click Save. The column should still show the same template pills afterwards — the post should not have had a value baked into its meta (verify via the single-post editor or Quick Edit that SEO title is still blank).

Verify the needs-improvement filter
13. Use the Needs improvement filter and select SEO title (and Social title on the Social tab).
14. The post from step 3 should not appear — its fallback template is non-empty, so it does not need improvement.

Verify posts with explicit values are unaffected
15. Find a post with an explicitly saved SEO title (visible in the single-post editor).
16. Confirm the column shows the stored value, the edit textarea is pre-filled with that value, and saving it writes it back unchanged.

Regression:

  • Go to Yoast SEO -> Settings -> Content types -> Posts
  • Check the Search appearance and test the replacment variables: Insert a replacement variable and save.
  • Repeat for social appearacance.

Relevant test scenarios

  • Changes should be tested with the browser console open
  • Changes should be tested on different posts/pages/taxonomies/custom post types/custom taxonomies — the fix applies to every post type that has a configured default template; verify with both Posts and Pages.
  • Changes should be tested on different editors (Default Block/Gutenberg/Classic/Elementor/other)
  • Changes should be tested on different browsers
  • Changes should be tested on multisite

Test instructions for QA when the code is in the RC

  • QA should use the same steps as above.

QA can test this PR by following these steps:

Impact check

This PR affects the following parts of the plugin, which may require extra testing:

  • The bulk editor's Search appearance tab — SEO title and meta description columns and their needs-improvement filter.
  • The bulk editor's Social appearance tab — social title and social description columns and their needs-improvement filter.
  • The Settings for content types.

Other environments

  • This PR also affects Shopify. I have added a changelog entry starting with [shopify-seo], added test instructions for Shopify and attached the Shopify label to this PR.
  • This PR also affects Yoast SEO for Google Docs. I have added a changelog entry starting with [yoast-doc-extension], added test instructions for Yoast SEO for Google Docs and attached the Google Docs Add-on label to this PR.

Documentation

  • I have written documentation for this change. For example, comments in the Relevant technical choices, comments in the code, documentation on Confluence / shared Google Drive / Yoast developer portal, or other.

Quality assurance

  • I have tested this code to the best of my abilities.
  • During testing, I had activated all plugins that Yoast SEO provides integrations for.
  • I have added unit tests to verify the code works as intended.
  • If any part of the code is behind a feature flag, my test instructions also cover cases where the feature flag is switched off.
  • I have written this PR in accordance with my team's definition of done.
  • I have checked that the base branch is correctly set.
  • I have run grunt build:images and committed the results, if my PR introduces or edits images or SVGs.

Innovation

  • No innovation project is applicable for this PR.
  • This PR falls under an innovation project. I have attached the innovation label.
  • I have added my hours to the WBSO document.

Fixes https://github.com/Yoast/reserved-tasks/issues/1370

…escription is empty

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 <noreply@anthropic.com>
@vraja-pro vraja-pro added the changelog: non-user-facing Needs to be included in the 'Non-userfacing' category in the changelog label Aug 4, 2026
@vraja-pro
vraja-pro requested a review from Copilot August 4, 2026 12:01

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates the bulk editor’s posts collection so seo_title and meta_description fall back to the post type’s configured templates (and for title also the installation default) when the stored values are empty, matching the single-post editor’s behavior.

Changes:

  • Introduces Default_Template_Resolver to resolve SEO title/meta description templates via wpseo_replace_vars() when stored values are empty.
  • Injects and applies the resolver in both Indexable_Posts_Collector and Post_Meta_Posts_Collector, and bases per-post needs_improvement verdicts on the resolved values.
  • Adds/updates unit tests covering template resolution behavior and updated constructor dependencies.

Reviewed changes

Copilot reviewed 13 out of 13 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
src/bulk-editor/infrastructure/posts/default-template-resolver.php Adds resolver for SEO title/meta description fallbacks from templates/defaults.
src/bulk-editor/infrastructure/posts/indexable-posts-collector.php Injects resolver; resolves title/description for API output and per-post verdict calculation.
src/bulk-editor/infrastructure/posts/post-meta-posts-collector.php Injects resolver; resolves title/description for API output and per-post verdict calculation.
tests/Unit/Bulk_Editor/Infrastructure/Posts/Default_Template_Resolver/Abstract_Default_Template_Resolver_Test.php Test base for resolver unit tests.
tests/Unit/Bulk_Editor/Infrastructure/Posts/Default_Template_Resolver/Resolve_Seo_Title_Test.php Verifies SEO title fallback chain and replace-vars behavior.
tests/Unit/Bulk_Editor/Infrastructure/Posts/Default_Template_Resolver/Resolve_Meta_Description_Test.php Verifies meta description template fallback and replace-vars behavior.
tests/Unit/Bulk_Editor/Infrastructure/Posts/Indexable_Posts_Collector/Abstract_Indexable_Posts_Collector_Test.php Updates collector test setup for new resolver dependency (default passthrough).
tests/Unit/Bulk_Editor/Infrastructure/Posts/Indexable_Posts_Collector/Get_Posts_Test.php Adds test asserting template resolution affects returned fields and verdicts.
tests/Unit/Bulk_Editor/Infrastructure/Posts/Post_Meta_Posts_Collector/Abstract_Post_Meta_Posts_Collector_Test.php Updates collector test setup for new resolver dependency (default passthrough).
tests/Unit/Bulk_Editor/Infrastructure/Posts/Post_Meta_Posts_Collector/Build_Needs_Improvement_Where_Test.php Updates double construction for new resolver dependency.
tests/Unit/Bulk_Editor/Infrastructure/Posts/Post_Meta_Posts_Collector/Build_Query_Args_Test.php Updates construction to provide resolver dependency.
tests/Unit/Bulk_Editor/Infrastructure/Posts/Post_Meta_Posts_Collector/Get_Posts_Test.php Adds test asserting template resolution affects returned fields and verdicts; adjusts get_post() fixtures for post_type.
tests/Unit/Doubles/Bulk_Editor/Post_Meta_Posts_Collector_Double.php Updates test double to accept and pass through new resolver dependency.

Comment thread src/bulk-editor/infrastructure/posts/indexable-posts-collector.php
Comment thread src/bulk-editor/infrastructure/posts/post-meta-posts-collector.php Outdated
Comment thread src/bulk-editor/infrastructure/posts/default-template-resolver.php Outdated
@coveralls-official

coveralls-official Bot commented Aug 4, 2026

Copy link
Copy Markdown

Coverage Report for CI Build 5303

Coverage at 47.887% (no base build to compare)

Details

  • Coverage remained the same as the base build.
  • Patch coverage: 44 uncovered changes across 5 files (158 of 202 lines covered, 78.22%).
  • No coverage regressions found.

Uncovered Changes

File Changed Covered %
src/bulk-editor/infrastructure/posts/post-meta-posts-collector.php 40 17 42.5%
src/bulk-editor/infrastructure/posts/default-template-resolver.php 26 16 61.54%
src/bulk-editor/user-interface/bulk-editor-integration.php 14 5 35.71%
packages/js/src/settings/initialize.js 1 0 0.0%
src/bulk-editor/infrastructure/posts/indexable-posts-collector.php 41 40 97.56%
Total (13 files) 202 158 78.22%

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 62442
Covered Lines: 31261
Line Coverage: 50.06%
Relevant Branches: 12568
Covered Branches: 4659
Branch Coverage: 37.07%
Branches in Coverage %: Yes
Coverage Strength: 9.2 hits per line

💛 - Coveralls

vraja-pro and others added 2 commits August 4, 2026 15:09
…_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 <noreply@anthropic.com>
… 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 <noreply@anthropic.com>
@FAMarfuaty FAMarfuaty added the innovation Innovative issue. Relating to performance, memory or data-flow. label Aug 4, 2026

@FAMarfuaty FAMarfuaty left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice, contained shape overall — one shared resolver injected into both collectors rather than duplicated logic, and a test class per method matching the repo convention. I haven't run the suite locally.

Four things I'd want resolved before merge:

  1. The query half of "needs improvement" wasn't updated, so the filter and the per-row flag now disagree — this is the thing the PR sets out to fix.
  2. Because the resolved value lands in the editable field, Edit → Save (and especially Apply all) permanently hardcodes template values into post meta.
  3. The social chain reads a Premium-only option that neither the editor nor Free's rendered output uses, and ignores the Open Graph setting.
  4. As a consequence of 3, the Social tab's "select all that need improvement" becomes a no-op.

The rest are suggestions and nits — reuse of Field_Renderer, de-duplicating the four resolve methods, hoisting the per-row template work, and a few test gaps.

One process note: if any of this leads to code changes, please update the PR body to match — the changelog entry, the technical-choices bullets and the test instructions (step 14 in particular) all describe the current behaviour.

Comment thread src/bulk-editor/infrastructure/posts/default-template-resolver.php Outdated
Comment thread src/bulk-editor/infrastructure/posts/default-template-resolver.php Outdated
Comment thread src/bulk-editor/infrastructure/posts/default-template-resolver.php Outdated
Comment thread src/bulk-editor/infrastructure/posts/default-template-resolver.php Outdated
Comment thread src/bulk-editor/infrastructure/posts/default-template-resolver.php
Comment thread src/bulk-editor/infrastructure/posts/indexable-posts-collector.php Outdated
Comment thread src/bulk-editor/infrastructure/posts/indexable-posts-collector.php Outdated
Comment thread src/bulk-editor/infrastructure/posts/post-meta-posts-collector.php Outdated
vraja-pro and others added 19 commits August 5, 2026 16:10
…ph 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 <noreply@anthropic.com>
…tions 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 <noreply@anthropic.com>
…s 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…mplate_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 <noreply@anthropic.com>
…nt template being saved to meta

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 <noreply@anthropic.com>
…ment query

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 <noreply@anthropic.com>
…vement

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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
* 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.
@github-actions

Copy link
Copy Markdown

A merge conflict has been detected for the proposed code changes in this PR. Please resolve the conflict by either rebasing the PR or merging in changes from the base branch.

…emeta-description-show-empty-instead-of-the-resolved-replacement-variable-template
@github-actions

Copy link
Copy Markdown

A merge conflict has been detected for the proposed code changes in this PR. Please resolve the conflict by either rebasing the PR or merging in changes from the base branch.

…70-bulk-editor-seo-titlemeta-description-show-empty-instead-of-the-resolved-replacement-variable-template

@FAMarfuaty FAMarfuaty left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Four items I think should be settled before this merges. Two are questions about a field that can never match the filter; two are JS bugs.

Comment thread src/bulk-editor/infrastructure/posts/indexable-posts-collector.php
Comment thread src/bulk-editor/infrastructure/posts/post-meta-posts-collector.php
Comment thread packages/js/src/bulk-editor/components/table/table-cells.js
Comment thread packages/js/src/bulk-editor/components/table/preview-editable-field-cell.js Outdated
@github-actions

Copy link
Copy Markdown

A merge conflict has been detected for the proposed code changes in this PR. Please resolve the conflict by either rebasing the PR or merging in changes from the base branch.

…70-bulk-editor-seo-titlemeta-description-show-empty-instead-of-the-resolved-replacement-variable-template
@vraja-pro
vraja-pro force-pushed the 1370-bulk-editor-seo-titlemeta-description-show-empty-instead-of-the-resolved-replacement-variable-template branch from cd21909 to 873333f Compare August 12, 2026 14:31
vraja-pro and others added 2 commits August 12, 2026 18:13
…or 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 <noreply@anthropic.com>
…nch, 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 <noreply@anthropic.com>
Comment thread packages/js/src/bulk-editor/components/table/table-row.js
Comment thread packages/js/src/bulk-editor/hooks/use-posts.js
Comment thread packages/js/tests/bulk-editor/bulk-editor-table.test.js
…ariable lookup works on initial load

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 <noreply@anthropic.com>
@FAMarfuaty FAMarfuaty added this to the feature/bulk-editor-ph2 milestone Aug 13, 2026
@FAMarfuaty
FAMarfuaty merged commit 55e1ffd into feature/bulk-editor-ph2 Aug 13, 2026
41 of 43 checks passed
@FAMarfuaty
FAMarfuaty deleted the 1370-bulk-editor-seo-titlemeta-description-show-empty-instead-of-the-resolved-replacement-variable-template branch August 13, 2026 12:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

changelog: non-user-facing Needs to be included in the 'Non-userfacing' category in the changelog innovation Innovative issue. Relating to performance, memory or data-flow.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants