feat(bulk-editor): add "needs improvement" filter options to the filters dropdown#23438
Conversation
Coverage Report for CI Build 713Coverage increased (+0.2%) to 55.559%Details
Uncovered Changes
Coverage Regressions3 previously-covered lines in 3 files lost coverage.
Coverage Stats💛 - Coveralls |
…cores Adds seo_title_score and meta_description_score post meta (0-100, mirroring content_score) plus matching indexable columns and a migration, so the assessor-based needs-improvement filter has a stored value to query. A default of 0 doubles as the never-scored sentinel. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Derives the SEO title and meta description scores from the existing SEO analysis results and writes them to their hidden fields, so they save as post meta alongside the overall score. A not-derivable score of 0 is never written, preserving any existing score. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nt filter Extends both posts collectors so the search-tab needs-improvement filter also matches posts whose stored SEO title or meta description score is in the bad/ok range (1-70), on top of the empty-field check. The social tab stays empty-only. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…onse The search update response now carries the SEO title and meta description with replacement variables resolved, so the bulk editor can re-score them on the value users actually see. Both fields are always returned so a focus keyphrase edit can be re-scored too. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds a POST /bulk_editor/update_scores route (and its endpoint) that persists the SEO title and meta description scores the bulk editor recomputes after a save, reusing the post access checks. Writing the meta rebuilds the indexable score columns through the normal watcher flow. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…cores Adds seo_title_score and meta_description_score post meta (0-100, mirroring content_score) plus matching indexable columns and a migration, so the assessor-based needs-improvement filter has a stored value to query. A default of 0 doubles as the never-scored sentinel. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Derives the SEO title and meta description scores from the existing SEO analysis results and writes them to their hidden fields, so they save as post meta alongside the overall score. A not-derivable score of 0 is never written, preserving any existing score. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nt filter Extends both posts collectors so the search-tab needs-improvement filter also matches posts whose stored SEO title or meta description score is in the bad/ok range (1-70), on top of the empty-field check. The social tab stays empty-only. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…onse The search update response now carries the SEO title and meta description with replacement variables resolved, so the bulk editor can re-score them on the value users actually see. Both fields are always returned so a focus keyphrase edit can be re-scored too. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds a POST /bulk_editor/update_scores route (and its endpoint) that persists the SEO title and meta description scores the bulk editor recomputes after a save, reusing the post access checks. Writing the meta rebuilds the indexable score columns through the normal watcher flow. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
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. |
…-filter-dropdown' of github.com:Yoast/wordpress-seo into 1346-bulk-editor-add-needs-improvement-filter-option-to-filter-dropdown
…-editor-add-needs-improvement-filter-option-to-filter-dropdown
After a search-appearance save the bulk editor re-runs the analysis worker on the rendered fields, derives the SEO title and meta description scores, and persists them, so the needs-improvement filter reflects edits without reopening the post editor. Enqueues the worker script data on the bulk editor page. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ad of the worker Re-scoring after an edit now runs only the SEO title and meta description assessors on the main thread, instead of spinning up the analysis worker and running the full page analysis to read four results. A JS filter (yoast.bulkEditor.analysis.researcher) lets Premium return a researcher with morphology data; Free scores with base word forms, matching the Free editor. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lysis is disabled The needs-improvement filter only matches on the stored per-field score while SEO analysis is enabled; when it is off the score is no longer maintained, so the search fields fall back to the empty-field check (like the social fields). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
After a search-appearance save the bulk editor re-scores the SEO title and meta description on the rendered field values and persists the result, so the needs-improvement filter reflects edits without reopening the post editor. Scoring runs the two field assessors directly on the main thread (no analysis worker, no full-page analysis); a JS filter (yoast.bulkEditor.analysis.configureResearcher) lets Premium augment the researcher with morphology data, while Free scores with base word forms. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lysis is disabled The needs-improvement filter only matches on the stored per-field score while SEO analysis is enabled; when it is off the score is no longer maintained, so the search fields fall back to the empty-field check (like the social fields). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…-filter-dropdown' of github.com:Yoast/wordpress-seo into 1346-bulk-editor-add-needs-improvement-filter-option-to-filter-dropdown
…rovement meta query
Applying an AI-generated SEO title or meta description now recomputes just that field's score via createSingleFieldScorer, wired through onFieldApplied, so the needs-improvement filter reflects AI edits the same as manual ones. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…core dot Relabels the options to the plain field names and adds a decorative red (#dc3232) score dot; a visually-hidden group legend keeps the "needs improvement" meaning for assistive tech. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
76359bb to
569e812
Compare
There was a problem hiding this comment.
Pull request overview
This PR expands the bulk editor’s filtering capabilities by introducing “needs improvement” filter options for search and social fields, backed by persisted per-field scores for SEO title and meta description to keep filtering and re-scoring consistent across the bulk editor and the post editor.
Changes:
- Adds persisted per-field score support for SEO title and meta description (meta sanitization + indexable columns + migration).
- Introduces a bulk-editor REST endpoint to persist computed per-field scores and returns rendered search fields from updates for client-side re-scoring.
- Adds a “needs improvement” filter to the bulk editor (UI + store/query param wiring + server-side collectors), including score-based matching when SEO analysis is enabled.
Reviewed changes
Copilot reviewed 56 out of 56 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/WP/Inc/Meta_Test.php | Adds WPSEO_Meta sanitization coverage for new score meta keys. |
| tests/Unit/Bulk_Editor/User_Interface/Scores_Route_Test.php | Unit tests for the new score update REST route. |
| tests/Unit/Bulk_Editor/User_Interface/Posts_Route/Register_Routes_Test.php | Updates posts route registration expectations (adds needs_improvement). |
| tests/Unit/Bulk_Editor/User_Interface/Posts_Route/Get_Posts_Test.php | Covers needs-improvement filtering + SEO-analysis-disabled behavior in posts route. |
| tests/Unit/Bulk_Editor/User_Interface/Posts_Route/Abstract_Posts_Route_Test.php | Adds Options_Helper dependency to posts route test setup. |
| tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Enqueue_Assets_Test.php | Verifies localized analysis config (locale + keywordAnalysisActive). |
| tests/Unit/Bulk_Editor/Infrastructure/Updates/Meta_Writer_Test.php | Adds test for persisting score meta via Meta_Writer. |
| tests/Unit/Bulk_Editor/Infrastructure/Posts/Indexable_Posts_Collector/Get_Posts_Test.php | Verifies SQL clause generation for needs-improvement in indexable collector. |
| tests/Unit/Bulk_Editor/Infrastructure/Endpoints/Update_Scores_Endpoint_Test.php | Tests endpoint URL/namespace/route for score updates. |
| tests/Unit/Bulk_Editor/Domain/Updates/Update_Result_Test.php | Ensures successful results can include rendered field payloads. |
| tests/Unit/Bulk_Editor/Domain/Updates/Post_Score_Update_Test.php | Tests new Post_Score_Update value object. |
| tests/Unit/Bulk_Editor/Domain/Posts/Posts_Query_Test.php | Covers new Posts_Query fields (needsImprovement + scoresEnabled). |
| tests/Unit/Bulk_Editor/Application/Updates/Score_Updater_Update_Test.php | Tests server-side score persistence workflow and error handling. |
| tests/Unit/Bulk_Editor/Application/Updates/Bulk_Updater_Update_Test.php | Asserts rendered search fields returned on successful search updates. |
| tests/Unit/Bulk_Editor/Application/Updates/Abstract_Bulk_Updater_Test.php | Wires a Field_Renderer test double into Bulk_Updater tests. |
| tests/Unit/Builders/Indexable_Post_Builder_Test.php | Extends indexable builder expectations to include new score fields. |
| src/models/indexable.php | Adds new indexable model properties for per-field scores. |
| src/config/migrations/20260709144332_AddSeoTitleAndMetaDescriptionScores.php | Migration adding score columns to the indexable table. |
| src/bulk-editor/user-interface/scores-route.php | New REST route to persist per-post field scores. |
| src/bulk-editor/user-interface/posts-route.php | Adds needs_improvement arg + passes scoring-enabled flag into Posts_Query. |
| src/bulk-editor/user-interface/bulk-editor-integration.php | Localizes analysis config (locale + keywordAnalysisActive) for JS scoring. |
| src/bulk-editor/infrastructure/updates/meta-writer.php | Adds write_score to persist score meta via canonical sanitization. |
| src/bulk-editor/infrastructure/updates/field-renderer.php | Renders meta values with replacement vars for accurate re-scoring. |
| src/bulk-editor/infrastructure/posts/post-meta-posts-collector.php | Implements needs-improvement filtering for meta-based collector. |
| src/bulk-editor/infrastructure/posts/indexable-posts-collector.php | Implements needs-improvement filtering for indexable-based collector. |
| src/bulk-editor/infrastructure/endpoints/update-scores-endpoint.php | Adds endpoint abstraction for update-scores route. |
| src/bulk-editor/domain/updates/update-result.php | Adds optional rendered payload support to update results. |
| src/bulk-editor/domain/updates/post-score-update.php | New domain object representing a post’s score update. |
| src/bulk-editor/domain/posts/posts-query.php | Extends query object with needs-improvement fields + scoresEnabled. |
| src/bulk-editor/application/updates/score-updater.php | Implements persistence logic for per-field score updates. |
| src/bulk-editor/application/updates/meta-writer-interface.php | Adds write_score contract. |
| src/bulk-editor/application/updates/field-renderer-interface.php | Defines rendering contract used by Bulk_Updater. |
| src/bulk-editor/application/updates/bulk-updater.php | Returns rendered search fields for re-scoring after saves. |
| src/bulk-editor/application/posts/posts-collector-interface.php | Introduces allowed needs-improvement field keys list. |
| src/builders/indexable-post-builder.php | Populates indexable score columns from meta. |
| packages/js/tests/helpers/fields/AnalysisFields.test.js | Adds tests for new hidden-field accessors in AnalysisFields. |
| packages/js/tests/bulk-editor/store/query.test.js | Updates query slice tests for needsImprovement support. |
| packages/js/tests/bulk-editor/services/use-posts.test.js | Verifies needs-improvement query param mapping + tab resolution. |
| packages/js/tests/bulk-editor/services/field-scores.test.js | Tests bulk-editor main-thread field scoring + persistence calls. |
| packages/js/tests/bulk-editor/hooks/use-inline-edit.test.js | Ensures inline edit wires up scorers and re-scores applied fields. |
| packages/js/tests/bulk-editor/bulk-editor-filters.test.js | Tests new filter UI options + accessibility grouping. |
| packages/js/tests/analysis/deriveFieldScores.test.js | Tests per-field score derivation and persistence to hidden inputs. |
| packages/js/src/initializers/analysis.js | Persists derived per-field scores during editor analysis runs. |
| packages/js/src/helpers/fields/AnalysisFields.js | Adds getters/setters for SEO title + meta description score inputs. |
| packages/js/src/elementor/helpers/is-relevant-change.js | Ignores score fields in Elementor “relevant change” detection. |
| packages/js/src/bulk-editor/store/query.js | Adds needsImprovement to query slice + actions/selectors. |
| packages/js/src/bulk-editor/services/use-posts.js | Sends needs_improvement param, resolved per active tab. |
| packages/js/src/bulk-editor/services/field-scores.js | New service to compute and POST per-field scores (bulk + single-field). |
| packages/js/src/bulk-editor/hooks/use-inline-edit.js | Triggers re-scoring after saves and after Premium AI applies values. |
| packages/js/src/bulk-editor/constants.js | Adds needs-improvement concepts and per-tab param mapping. |
| packages/js/src/bulk-editor/components/bulk-editor-filters.js | Adds needs-improvement filter group and applied-count badge updates. |
| packages/js/src/analysis/refreshAnalysis.js | Persists derived per-field scores on analysis refresh. |
| packages/js/src/analysis/deriveFieldScores.js | Implements score derivation from assessment results + hidden field writes. |
| inc/class-wpseo-meta.php | Registers new hidden meta fields and sanitizes them to 0–100. |
| css/src/bulk-editor-page.css | Adds styling for the new filter group and red dot indicator. |
| admin/metabox/class-metabox.php | Disables saving score meta when SEO analysis is disabled. |
Both collectors duplicated the 1-70 "needs improvement" band (MIN/MAX ints in the indexable collector, a [1,70] array in the post-meta fallback). Move it to Posts_Collector_Interface::NEEDS_IMPROVEMENT_MIN_SCORE / MAX_SCORE and reference it from both, so the band lives in one place. Also aligns the $needs_improvement @param wording with the property doc. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…s-improvement-filter-option-to-filter-dropdown
|
AC 🚧
|
|
Thanks for the AC pass @vraja-pro — went through all three: ② SEO title filter returns no posts — Genuine bug, fixed. The post-meta collector (the path used when indexables are disabled) expressed "empty OR missing" through a WP_Query ① SEO data optimization gets stuck — This is the migration not having run in your environment, not the indexation code. ③ Can't save an empty title / meta description — I couldn't reproduce this from the current branch. There's no empty/truthy guard in the save path (Free's |
|
AC 🚧 When maually editing a row, I get 6 requests in the network tab. The per-row "Save" button (handleSave in table-row.js) calls onApplyField for each open field via forEach. Each onApplyField call fires 1 POST to the save endpoint, plus (before the current branch's fix) 1 POST to update_scores = 2 per field × 3 fields = 6 requests. The current branch already adds the conditional rescore (remainingOpenFields.length === 0). But that fix has a subtle bug: all 3 onApplyField calls are dispatched synchronously from forEach, so they all read the same rowEdit snapshot. None will ever see remainingOpenFields.length === 0, so the rescore never fires from the per-row Save button. The right fix: the per-row "Save" button should batch all open fields into one request per endpoint (like onApplyAll does) and rescore once after. For the current field sets, all 3 fields share the update_search endpoint → 2 requests total (1 save + 1 rescore). |
…yRow Replaces the per-field loop (N separate POSTs) on the row Save button with a single onApplyRow call that merges all open fields into one request per endpoint. Updates tests to reflect the new batching behaviour. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
vraja-pro
left a comment
There was a problem hiding this comment.
CR & AC ✅
I ended up fixing it myself, we can create a followup issue if there is any objection.
Context
The bulk editor's filter dropdown had no way to surface posts whose search-appearance or social fields need attention. This PR adds "needs improvement" filter options for the SEO title, meta description, social title and social description. It is part of bulk editor phase 2 (base branch
feature/bulk-editor-ph2). The companion Premium PR Yoast/wordpress-seo-premium#5027 adds morphology so the bulk editor's scores match the post editor on Premium sites.Summary
This PR can be summarized in the following changelog entry:
Relevant technical choices:
0score is the "never scored" sentinel, deliberately outside the range, so unscored posts match only through the empty check. Backfilling existing posts is out of scope — scores populate as posts are opened in the editor or edited in the bulk editor.linkdex→primary_focus_keyword_score) and readability score (content_score→readability_score) already behave.Indexable_Post_Builderpopulates all four purely by copying post meta — nothing is scored server-side (scoring is browser-JS only). Historical posts carrylinkdex/content_scoreonly because that meta accumulated from editor saves; a post never opened in the editor has no SEO/readability score either. The "SEO data optimization" step in the first-time configuration is just the indexation process (it copies existing meta into the indexable table — "it won't change any content"), so it does not compute or backfill the new per-field scores. Existing posts therefore show empty per-field scores until re-saved in the editor or bulk-edited.content_score(post meta → indexable columns via the meta watcher); a migration adds two nullable int columns.SeoTitleAssessor/MetaDescriptionAssessor) directly on the main thread against the server-rendered field values, instead of booting the analysis worker and running a full analysis, to keep the bulk-editor page light. A manual edit re-scores from the rendered response; an applied AI suggestion (Premium) re-scores just that one field from its literal value.@wordpress/hooksfilter seam (yoast.bulkEditor.analysis.configureResearcher), implemented in Yoast/wordpress-seo-premium#5027.Important
For more detailed documentation on the approach and technical choices, please refer to this doc below 👇🏽
bulk-editor-needs-improvement-filter.md
Test instructions
Test instructions for the acceptance test before the PR gets merged
This PR can be acceptance tested by following these steps.
Setup and migration
yarn build/grunt build) and activate it. On activation theAddSeoTitleAndMetaDescriptionScoresmigration runs, adding theseo_title_scoreandmeta_description_scorecolumns to the indexable table (wp_yoast_indexable). Confirm both columns exist. (On a dev checkout the migration usually needs to be run manually — see Running the migration manually below.)Running the migration manually
On a dev checkout the plugin version usually already matches the stored version, so the migration runner's version gate skips it and the two columns are never added. Force the pending migrations with WP-CLI (in a Docker /
wp-envsetup, run it inside the container, e.g.docker exec --user www-data <container> wp ...):The
"99.0.0"target only has to exceed the stored version so the gate lets the run proceed; the runner then applies every not-yet-run migration, includingAddSeoTitleAndMetaDescriptionScores. Verify the columns were added:Look for
seo_title_scoreandmeta_description_scorein the output. The migration'sdown()removes both again on a rollback.Filter dropdown appearance (match the Figma design)
Open the Filters dropdown on the Search appearance tab and compare it against the Figma design. Each "needs improvement" option ("SEO titles", "Meta descriptions") should show a round red score bullet (#dc3232, ~12px) before its label, the section should be separated from the status filters by a divider, and there should be no visible "Needs improvement" heading (it exists only for screen readers). Confirm the bullet's size, colour and spacing match the design.
The filter — empty fields
The filter — with indexables disabled (post-meta fallback)
The bulk editor reads from post meta instead of the indexable table when indexables are turned off. Disable them with a snippet (mu-plugin or Code Snippets):
Make sure you have (a) a post that has never had an SEO title (so it has no
_yoast_wpseo_titlemeta row at all) and (b) a post whose SEO title was set and then cleared to empty. On the Search appearance tab, apply "SEO title needs improvement": both posts appear. Repeat for "Meta description needs improvement", and for the social fields on the Social appearance tab. With SEO analysis enabled, a filled title scored 1–70 also appears; a filled title scored 71+ does not.The filter — weak scores (search tab only)
Each field's score is the average of its two assessments (exact per-assessment thresholds:
packages/yoastseo/src/scoring/assessments/SCORING SEO.md), and a field is flagged when that average lands in the 1–70 band. A normal-length title/description already scores green on width/length, so the keyphrase's presence is the easiest lever.Craft a post that NEEDS improvement (non-empty, so it exercises the score path — not the empty check). On a post, set a focus keyphrase (e.g.
banana bread), then:Delicious homemade loaf→ SEO title width green (9) + Keyphrase in SEO title red (2) → score ≈ 61.Open/save the post so the scores persist. In the bulk editor, both "SEO title needs improvement" and "Meta description needs improvement" now list it.
Craft a post that does NOT need improvement. Same keyphrase, then:
Banana bread: easy homemade recipe→ both title assessments green → score 100.Neither filter lists this post. Also confirm: a good-scored (71+) filled title does not appear, and a filled field that was never scored does not match the score band (it only matches the empty check — a documented gap for template-defaulted fields).
Updating the score from a manual edit
POST yoast/v1/bulk_editor/update_scores; refresh/refetch and confirm the row leaves or joins the filtered set according to its new score. Editing the focus keyphrase re-scores both the SEO title and the meta description.Updating the score from AI generation (Premium)
update_scoresPOST fires for the applied field and, on refetch, the row leaves/joins the filtered set exactly like a manual edit. (Applying an AI suggestion re-scores only that field.)When SEO analysis is disabled — Yoast settings (site-wide)
update_scoresrequest, and opening/saving a post in the editor clears its stored score.When SEO analysis is disabled — user profile (per-user)
When Premium is not activated
wpseoPremiumBulkEditorMorphologyis absent). The filter and the manual-edit re-score still work; scores are computed with base word forms. (AI generation is a Premium feature and is not present.)When Premium is activated — morphology parity
Right-to-left (RTL)
Switch to an RTL language (Settings → General → Site Language, or the user profile language — e.g. Arabic or Hebrew) and reopen the Filters dropdown. The score bullet uses logical spacing, so confirm the layout mirrors correctly: the red bullet sits before its label on the right-hand side, the checkbox / bullet / label order reads right-to-left, and the dot is neither clipped nor misplaced.
Editors
Relevant test scenarios
update_scoresrequest (manual and AI apply).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:
WPSEO_Meta(two newgeneralfields) and the indexable schema (migration adding two columns).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 Yoast/reserved-tasks#1346