Skip to content

Add the settings OptionsPage and word built-in field rejections - #30

Merged
ahegyes merged 11 commits into
trunkfrom
feat/options-page
Oct 19, 2026
Merged

ahegyes merged 11 commits into
trunkfrom
feat/options-page

Conversation

@ahegyes

@ahegyes ahegyes commented Oct 19, 2026 •

Copy link
Copy Markdown
Owner

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\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; row labels point at their controls, except radio, checkboxes and a checkbox with a desc, which their legend names; titles print as text, and an untitled field is named by its id.
  • Each tab's option is registered in a settings group of its own name, so options.php requires the page's capability.
  • 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 the option's own options.php submission, 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.
  • The page prints the settings errors itself, except under Settings, where core prints them.
  • Other plugins add tabs and fields through the {id}_tabs filter.

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-bootstrap
  • wp-framework-shared
  • wp-framework-core
  • wp-framework-settings
  • wp-framework-woocommerce

Checklist

  • Tests added or updated:
    • Integration: tests/Integration/Settings/OptionsPageTest.php holds 44 rows that post through WordPress core's own admin.php and options.php, each request in its own PHP process, with forms posted back as a browser serializes them; SanitizeValueTest asserts each code's message.
  • composer quality-check passes locally (Unit 200, Integration 685), plus composer test:integration:multisite (26) and PHP 7.4 php -l over packages/bootstrap
  • Public API changes are documented in the affected package's README.md (the package READMEs stay pointer-form; AGENTS.md carries the file map)
  • Breaking changes are explicitly justified below (none: OptionsPage is new, and the rejection messages fill what was empty)

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
@ahegyes ahegyes changed the title Feat/options page Add the settings OptionsPage and word built-in field rejections Oct 19, 2026
@ahegyes
ahegyes merged commit 6ab5d49 into trunk Oct 19, 2026
14 of 15 checks passed
@ahegyes
ahegyes deleted the feat/options-page branch October 19, 2026 14:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant