Repository navigation
Add the settings OptionsPage and word built-in field rejections - #30
Merged
Merged
Conversation
Convention 10 capped the framework's translated strings at five, which pushed hosts to word every rejection themselves. The owner lifted the cap, so the convention keeps only the domain and the owning packages. Assisted-by: Claude Code:claude-opus-5-5
Both lockfiles were behind their latest releases. Composer moves only roave/security-advisories, and npm moves the @php-wasm packages under @wordpress/env from 3.1.57 to 3.1.58. No direct dependency was outdated, the configs workflows already pin configs trunk 6302f1f, and no code change was needed. Assisted-by: Claude Code:claude-opus-5-5
A rejection from Forms::sanitize_value() carried a code and an empty message, so every host had to word each code itself or fall back to a generic message that names the field but not what is wrong. The rejection rows now expect each code's message, which says what to enter and gives an example where one helps. 32 rows fail: the messages are still empty. Assisted-by: Claude Code:claude-opus-5-5
Rejections from Forms::sanitize_value() carried only a code, so a host could name the field that failed but not say what was wrong or what to enter instead. Each code now carries a translated message in the framework's text domain, built only when a submission is rejected, so nothing translates at include or registration time. The messages say what to enter and give an example where one helps. A host may still replace a message by its code. The settings package now ships strings, so its manifest declares the text domain the scoper rewrites. Assisted-by: Claude Code:claude-opus-5-5
The settings package had no host for its field arrays on a WordPress options screen. These rows pin OptionsPage's behavior through WordPress core's own admin.php and options.php: the menu entry and page access follow the capability, each tab renders its form and saves its own option row, fields are validated once on a first save, rejections keep the stored value and show one message that names the field and says what to enter, disabled and unrendered fields stay untouched, programmatic writes pass through, and a contributor's tabs and fields arrive through the page's tabs filter. The id rows run through a case provider that every later helper constructor reuses. Core's admin files read and write globals at file scope, so each request runs in its own PHP process at global scope. A fixture registers the page with a plain closure on plugins_loaded, from a spec the test passes, and forms are posted back as a browser serializes them. 44 rows fail: the class does not exist yet. Assisted-by: Claude Code:claude-opus-5-5
Nothing hosted the settings field arrays on a WordPress options screen, so every plugin would have wired register_setting(), the menu entry, the tab forms and the sanitizing on its own. Settings\OptionsPage takes an id and a definition closure, evaluated after init on first use, and a plugin calls its register_hooks() itself, with or without the kernel.
- The page is a submenu entry under its parent for users holding its capability. Each tab renders one form through the Settings API, and every row label points at its control, except for radio, checkboxes and a checkbox with a desc, which their legend names. Titles print as text.
- Each tab's option is registered in a settings group of its own name, so options.php requires the page's capability through option_page_capability_{option}.
- The submission is sanitized on pre_update_option_{option}, which runs once per save, even on a first save that falls through to add_option(). It acts only on this option's own options.php submission, read from the request and trimmed as WordPress core reads it, so programmatic writes pass through. The accepted fields replace theirs in the stored row; a rejected field keeps its value and adds one settings error that names it and says what to enter, or that its value is not valid when its rejection has no message.
- The page prints the settings errors itself, except under Settings, where WordPress core prints them.
- Other plugins add tabs and fields through the {id}_tabs filter.
Forms::sanitize_fields() declares its submission's keys as array-key, because PHP turns numeric field ids into integer keys. The menu row of the red tests looked for the parent file in the submenu link, which WordPress core writes as admin.php under Tools; the row now matches the page query.
Assisted-by: Claude Code:claude-opus-5-5
AGENTS.md named no limitation of the options page's error reporting. A rejected field is reported in the page's notices, as WordPress core reports its settings errors, and after options.php redirects back the field shows its stored value, because the rejected input does not survive the redirect. Assisted-by: Claude Code:claude-opus-5-5
The programmatic-update row passed with OptionsPage's options.php check removed, because its request carried no option_page, so the option_page match alone let the write through. The request now names the tab's option as option_page from admin-post.php, so only the options.php check keeps the write as given. Assisted-by: Claude Code:claude-opus-5-5
A field without a title rendered an empty row label, so its control had no accessible name and a screen reader could not tell users what it was for; a radio or checkboxes group lost its legend the same way. Settings errors already named such a field by its id, so the row label and the legend now follow the same rule through one helper. Assisted-by: Claude Code:claude-opus-5-5
Three docblocks narrated their code: is_valid_key() spelled its pattern out in words, render_page() listed the steps it prints, and render_form() repeated why grouped controls have plain-text row labels, which Forms::render_control() already states. They now say what each element does and keep only the reason a reader cannot see. The definition parameter states that the closure must not throw, as a requirement on the caller. Assisted-by: Claude Code:claude-opus-5-5
Each test that ran admin requests created a session token for every user it logged in and left it in the user's session_tokens meta, so the reused test users gathered a token per test per run until the day-long tokens expired. The trait now destroys the tokens it created after each test. Assisted-by: Claude Code:claude-opus-5-5
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The settings package had no host for its field arrays on an options screen, so every plugin would have wired
register_setting(), the menu entry, the tab forms and the sanitizing itself, and a rejected field could only be named, not explained.DeepWebSolutions\Framework\Settings\OptionsPagetakes an id and a definition closure, evaluated afteriniton first use, and a plugin calls itsregister_hooks()itself, with or without the kernel.?tab=renders one form through the Settings API; row labels point at their controls, except radio, checkboxes and a checkbox with adesc, which their legend names; titles print as text, and an untitled field is named by its id.options.phprequires the page's capability.pre_update_option_{option}, which runs once per save, even on a first save that falls through toadd_option(). It acts only on the option's ownoptions.phpsubmission, read from the request and trimmed as core reads it, so programmatic writes pass through. Accepted fields replace theirs in the stored row; a rejected field keeps its value and adds one settings error that names it and says what to enter.{id}_tabsfilter.Forms::sanitize_value()now words each rejection code ("Enter a valid email address, such as name@example.com.") in the framework's text domain, built only when a submission is rejected; hosts can still replace a message by its code. The settings manifest declares the text domain, and AGENTS.md drops the cap on framework strings and records where the page reports rejections.Affected packages
wp-framework-bootstrapwp-framework-sharedwp-framework-corewp-framework-settingswp-framework-woocommerceChecklist
tests/Integration/Settings/OptionsPageTest.phpholds 44 rows that post through WordPress core's ownadmin.phpandoptions.php, each request in its own PHP process, with forms posted back as a browser serializes them;SanitizeValueTestasserts each code's message.composer quality-checkpasses locally (Unit 200, Integration 685), pluscomposer test:integration:multisite(26) and PHP 7.4php -loverpackages/bootstrapREADME.md(the package READMEs stay pointer-form; AGENTS.md carries the file map)OptionsPageis new, and the rejection messages fill what was empty)