fix(bulk-editor): resolve post type default template when SEO title/description is empty - #23538
Conversation
…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>
There was a problem hiding this comment.
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_Resolverto resolve SEO title/meta description templates viawpseo_replace_vars()when stored values are empty. - Injects and applies the resolver in both
Indexable_Posts_CollectorandPost_Meta_Posts_Collector, and bases per-postneeds_improvementverdicts 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. |
Coverage Report for CI Build 5303Coverage at 47.887% (no base build to compare)Details
Uncovered Changes
Coverage RegressionsNo coverage regressions found. Coverage Stats💛 - Coveralls |
…_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
left a comment
There was a problem hiding this comment.
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:
- 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.
- Because the resolved value lands in the editable field, Edit → Save (and especially Apply all) permanently hardcodes template values into post meta.
- The social chain reads a Premium-only option that neither the editor nor Free's rendered output uses, and ignores the Open Graph setting.
- 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.
…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.
|
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
…emeta-description-show-empty-instead-of-the-resolved-replacement-variable-template
|
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
left a comment
There was a problem hiding this comment.
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.
|
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
cd21909 to
873333f
Compare
…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>
…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>
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 theReplacementVariableEditorso it is displayed as styled variable pills rather than resolved text. BothIndexable_Posts_CollectorandPost_Meta_Posts_Collectornow inject and call the resolver to produce a*_fallbacksibling for each editable field.Summary
This PR can be summarized in the following changelog entry:
Relevant technical choices:
Default_Template_Resolverclass. The fallback logic is extracted into a dedicated infrastructure class rather than duplicated across the two collectors. A privateresolve()helper covers all four fields; social fields additionally guard on theopengraphoption.title-{post_type}) → installation default. Meta description stops at the user-configured template. Social title follows the same chain usingsocial-title-{post_type}(installation default%%title%%); social description mirrors meta description usingsocial-description-{post_type}.seo_title(the raw stored value, empty when never saved) andseo_title_fallback(the post type template, empty when a value is already stored). The read-only column display showsstored || 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_improvementverdict uses the resolved value. The emptiness check inbuild_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.Test instructions
Test instructions for the acceptance test before the PR gets merged
This PR can be acceptance tested by following these steps:
Setup
%%title%% %%sep%% %%sitename%%. This is the default and is usually already configured.%%title%%).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:
Yoast SEO->Settings->Content types->PostsRelevant test scenarios
Test instructions for QA when the code is in the RC
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:
Other environments
[shopify-seo], added test instructions for Shopify and attached theShopifylabel to this PR.[yoast-doc-extension], added test instructions for Yoast SEO for Google Docs and attached theGoogle Docs Add-onlabel to this PR.Documentation
Quality assurance
grunt build:imagesand committed the results, if my PR introduces or edits images or SVGs.Innovation
innovationlabel.Fixes https://github.com/Yoast/reserved-tasks/issues/1370