diff --git a/AGENTS.md b/AGENTS.md index 8b02c52..87ad366 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,7 +26,7 @@ wordpress-framework/ │ └── Uninstall/Uninstaller ├── packages/settings/ ahegyes/wp-framework-settings `require.php` · core ^2.0 │ └── src/ psr-4 DeepWebSolutions\Framework\Settings\ · Forms (render_control, sanitize_fields, is_disabled, is_group, get_title, get_rejection_message, check_helper_id, render_hidden_input, escape_attribute, get_rows (@internal)) -│ ├── AdminPage/{OptionsPage, OptionsPageDefinitionInterface} · pages store each tab's fields in one option through the Settings API +│ ├── AdminPage/{OptionsPage, OptionsPageDefinitionInterface} · pages store each field in its own option through the Settings API │ └── EditScreen/{ObjectForm, ObjectFormDefinitionInterface, ObjectFormHostInterface, ObjectStoreInterface, FormTable (@internal), Hosts/{PostMetaBox, UserProfile, TermForm}, Stores/MetadataStore} · forms store each field on the edited object through a store ├── packages/woocommerce/ ahegyes/wp-framework-woocommerce `require.php` · settings ^2.0 · WooCommerce as host, at the plugin's `WC requires at least` │ └── src/ psr-4 DeepWebSolutions\Framework\WooCommerce\ · WooCommerceRequirement @@ -83,7 +83,7 @@ actionlint && zizmor .github/ 5. **Safeguards.** A plugin's safeguards are one final class in `Safeguards.php` at the plugin root, beside the entry, which no autoload root maps; everything a safeguard calls lives in it or files it requires. The entry requires it by path before `load.php` and the floor gate and calls its one public static entry point, so a safeguard runs on any runtime that parses the plugin: it parses on PHP 7.4 and calls only what its hook hands it, WordPress APIs present at the oldest WordPress the bootstrap package runs on, which `phpstan.bootstrap.neon` sets, and, in a plugin that requires WooCommerce, WooCommerce APIs present since 3.6, guarding anything newer. A safeguard that calls WooCommerce is hooked from the class's one `woocommerce_loaded` callback, since WordPress loads a plugin while WooCommerce is inactive. Each safeguard is a static closure, which no code can remove by name, so relaxation happens only through the safeguard's own filter, after `{root}/boot/succeeded`, on an explicit `false`; helpers are protected, and only what other classes call is public. 6. **Shared logic.** The global namespace holds only a plugin's contract. `functions.php` declares its `@api` functions, the accessor among them, which returns the composition root while the root's members stay internal unless tagged `@api`, and registers its hooks as static closures; `includes/`, which `functions.php` loads, holds more `@api` functions and constants, grouped by topic. Every other symbol is a member of a final class in `src/` named for its concept: the composition root, which also holds the footprint, a component, or a class that registers no hooks, such as `Options`, whose constants name the stored settings and their defaults and whose public statics read them, and `Migrations`, whose one public method lists its protected static steps as first-class callables, while a batched step is a final class of its own and a meta key stays on the class that stores it. Built scripts and styles register through core's `Assets`, which the container defines. No namespace or folder is named for being internal. Per-request state lives in a static holder or `WC()->session`. No service locator. 7. **Migration steps.** Append-only; a retired step keeps its code or throws. Converge from any partial state; verify writes and throw on failure, including a scheduler write that returns `WP_Error`. Role capabilities are granted by `Permission\CapabilityGrants`, never by a step. Create resources resumably (lookup marker, explicit `post_author`). Use WC CRUD for orders and products. A plain step finishes well under 10 minutes; bulk work is a `BatchedMigrationStepInterface` step, run only from the runner's scheduled hook, at about 1–2 s per batch. Steps run on `wp_loaded`, so every `init` registration exists. One linear list serves every build variant. -8. **Settings.** Full keys are the plugin's prefixed option constants; WC ids are flat. Boot-time defaults are constants; translated defaults come from the plugin's text reader after `init`. Validate on write, and guard every typed read: `filter_var( $raw, FILTER_VALIDATE_*, FILTER_NULL_ON_FAILURE ) ?? $default`, or a strict `in_array()` for a choice. Direct meta writes go through `wp_slash()`. The framework detects a failed query only where it runs the query itself; a WordPress or Action Scheduler API's failure is accepted as the API reports it. The uninstall engine is the exception, because it keeps consent until a pass runs clean. Framework components build every option, transient and meta name they store from the identity's prefix, as `{prefix}_`, so the plugin's footprint covers it: the runner's options, which start with `{prefix}_revision`, the grants' `{prefix}_capabilities` ledger, a `Lock`'s `{prefix}_{name}`, and a `TransientCache` region's generation option, `{prefix}_{generation}_cache_generation`, whose entries, transients that all carry a timeout, expire on their own. An `ObjectCache` region's group, `{prefix}_{region}`, keeps it apart from core's and other plugins' groups. The runner's scheduled hook, `{hook_root}/migration/run`, and the scheduler's Action Scheduler group, the bare `{prefix}`, fall outside it, so the footprint names them under `cron_hooks` and `as_groups`. The bootstrap updater's release caches carry the text domain instead and expire on their own. WooCommerce multiselect option keys are truthy, because WooCommerce's save drops `'0'` and `''`. +8. **Settings.** Each setting is one autoloaded option on every host, its field id the whole, flat option name, which on an options page starts with the plugin's `{prefix}_`. Defaults are constants in stored form; a translated default is never stored, and the component that shows the setting supplies it after `init`. A number field states its bounds only in `custom_attributes`. Validate on write, and guard every typed read: `filter_var( $raw, FILTER_VALIDATE_*, FILTER_NULL_ON_FAILURE ) ?? $default`, or a strict `in_array()` for a choice. Direct meta writes go through `wp_slash()`. The framework detects a failed query only where it runs the query itself; a WordPress or Action Scheduler API's failure is accepted as the API reports it. The uninstall engine is the exception, because it keeps consent until a pass runs clean. Framework components build every option, transient and meta name they store from the identity's prefix, as `{prefix}_`, so the plugin's footprint covers it: the runner's options, which start with `{prefix}_revision`, the grants' `{prefix}_capabilities` ledger, a `Lock`'s `{prefix}_{name}`, and a `TransientCache` region's generation option, `{prefix}_{generation}_cache_generation`, whose entries, transients that all carry a timeout, expire on their own. An `ObjectCache` region's group, `{prefix}_{region}`, keeps it apart from core's and other plugins' groups. The runner's scheduled hook, `{hook_root}/migration/run`, and the scheduler's Action Scheduler group, the bare `{prefix}`, fall outside it, so the footprint names them under `cron_hooks` and `as_groups`. The bootstrap updater's release caches carry the text domain instead and expire on their own. WooCommerce multiselect option keys are truthy, because WooCommerce's save drops `'0'` and `''`. 9. **Object forms** render through `Forms::render_control()`, print their nonce bound to the object and verify it before they evaluate their definition; every framework saver goes through `Forms::sanitize_fields()` and leaves absent (unrendered or disabled) fields untouched, but for limitation 4's WooCommerce checkboxes and multiselects. 10. **Translation, by-path files, Plugin Check.** Never translate at include or registration time. Framework strings use only the `'wp-framework'` domain, owned by core, settings and woocommerce; bootstrap and shared ship none, so a boot notice builds its translated message in its render callback and prints it through `Requirements::print_admin_notice()`. The bootstrap package stays 7.4-parseable, loaded by path, outside every `autoload` key, and defines code only as final classes with public static entry points; consumers require only its `load.php`, whose path is public API. Code is Plugin-Check-clean, using inline `phpcs:ignore` with a reason; local PHPCS runs the full `WordPress` standard, which covers the WordPress sniffs Plugin Check runs, and Plugin Check's own `PluginCheck` sniffs over the packages, which Plugin Check skips in a plugin's `vendor-prefixed/`. @@ -115,7 +115,7 @@ Releases follow SemVer on this contract from the first tag: a break of it ships 1. A throw in `register_hooks()` leaves earlier hooks live, and side effects are never undone. Safeguards still ignore relaxations, because `{root}/boot/succeeded` never fires. Gates evaluate once, at `plugins_loaded`. Components gated on the data revision switch on one request after the migrating request. The request that activates the plugin does not boot it, because WordPress includes the plugin's file after `plugins_loaded`, so its post types and endpoints first register on the next request. 2. A request killed mid-migration holds the lock until 10 minutes after the step or batch it was running began, then the step re-runs and the error row names the step that did not finish. One primary database is assumed. While a plain step's failure persists in production, each request issues one `INSERT IGNORE` plus one `SELECT`, one more `SELECT` until a first step succeeds, and admin screens read the error row; in local and development environments the lock is released, so every request re-runs the failing step. While a batched step is pending, a request outside the runner's hook takes no lock and only looks the hook up in the scheduler, and while its failure persists, each queue run fires the runner's hook to no effect and the failure is logged every 10 minutes. A WP-CLI process is one request, so once it has spent the runner's time budget, a long `wp action-scheduler run` stops running the migration, and the next request or process continues it. After a rollback, older code runs on newer data until the plugin is upgraded again; the written-back revision then re-runs the newer steps, and `{prefix}_revision_high_water` keeps the highest revision reached. Mixed-version rolling deploys may re-run steps, which converge. 3. Uninstall visits every site of a network and cleans each one that consents. The engine needs the plugin's PHP and WordPress floors, so where a site fell below them a web request deletes the plugin's files and leaves its data and cron events until the plugin is installed and deleted again on a compliant runtime, while WP-CLI fails and keeps the files. A throw from another plugin's hook ends that site's pass, and the rest of its footprint and its consent row wait for the next uninstall. A web request on a large network refuses and names `wp plugin uninstall`; on a network of thousands of sites, or on a single site whose footprint holds many rows, a web request can still exceed the server's time limit, and a retry or `wp plugin uninstall` completes it. Where the object cache cannot flush its runtime cache, every visited site's options stay in memory until the request ends. Network options and network-global user meta go only on a single site or on the consent constant. Exact `options`, `transients` and `network_options` names match under the database collation, as WordPress's option API matches them. An `option_prefixes` or `user_meta_global_prefixes` entry that the table prefix starts with, such as `wp_` on a default install, also deletes the site's roles and every user's capabilities, which WordPress names after the table prefix; WordPress reserves `wp_` for itself. On an installation of several networks, the large-network bound counts the current network's sites, and the network pass deletes every network's options but clears only the current network's cached values. Action Scheduler actions stay queued when it is not loaded at uninstall, and fail once it runs them, since no callback is registered; on a site without its tables, unscheduling logs a database error and does nothing else; a cancel Action Scheduler's default store cannot write repeats until the request's time limit; a job that cannot be unscheduled is logged and left. -4. Over WC REST, `validate` receives WooCommerce's normalized value: unknown multiselect choices are already dropped and text already sanitized. A rejected value is not stored, but the response echoes it. Grouped `option[key]` ids would bypass the bridge, hence flat ids. WC REST lists only WooCommerce's own field types, so custom-type and `checkboxes` fields are not reachable there. A product field's `validate` runs only on the classic product edit form; WC REST `meta_data`, CSV import and CRUD writes store what they are given. An object form field's `validate` runs only on its form; REST, imports, meta and order CRUD writes store what they are given, and so does a Custom Fields box for a key without a leading underscore: core's on a post type that supports `custom-fields`, orders in the posts table included, and WooCommerce's on the order tables' edit screen. WC-rendered controls carry WooCommerce's markup, and the row printed around a custom-type or `checkboxes` field on a WooCommerce settings screen or the product data panel imitates it. On a WooCommerce settings screen a field's `render` callable needs a type WooCommerce does not draw: on one of its own types WooCommerce draws its control and the callable never runs. The framework guarantees markup only for `Forms::render_control()`. `OptionsPage` and `ProductDataTab` report a rejected field in the page's notices, not beside the field, which after the redirect shows its stored value rather than the rejected input. `ObjectForm` reports it as a one-shot user notice through the plugin's `AdminNotices`, shown in the post editor after the save, or else on the user's next admin screen of the site it was saved on, the network and user admin counting as the main site's; a newer rejection of the same field, on any object, replaces it, and a rejection whose notice fails to save goes unreported. On the profile and in the block editor, which never validate the form, a number the browser cannot parse posts as empty and clears the stored value. With WooCommerce's beta `settings-ui` feature on, the sections a framework tab defines stay classic, and a framework section registered on any tab renders in its Settings UI, which saves through the same form post. A field the Settings UI's schema refuses, such as a `checkboxes` or custom-type field holding an array or a radio listing disabled choices, renders that section classic, with a `_doing_it_wrong` notice under `WP_DEBUG`; a field it has no control for, such as one of those types holding no array or WooCommerce's `color`, makes the page fail to render and offer WooCommerce's "Use classic settings" link, under which every field saves. On a WooCommerce settings screen a cleared checkbox or multiselect posts nothing, so one the section does not render at save is stored cleared. +4. Over WC REST, `validate` receives WooCommerce's normalized value: unknown multiselect choices are already dropped and text already sanitized. A rejected value is not stored, but the response echoes it. Grouped `option[key]` ids would bypass the bridge, hence flat ids. WC REST lists only WooCommerce's own field types, so custom-type and `checkboxes` fields are not reachable there. WooCommerce's `rest-api-v4` routes, a feature it ships off, store the fields on its own tabs through `update_option()`, so no `validate` or number bound runs there. A number field with a `step` and no parseable `min` counts its steps from zero on save, while the browser counts them from the value the control renders with, so a rendered value off the grid from zero is rejected at each save, and where the browser validates the form, the field saves no other number. A product field's `validate` runs only on the classic product edit form; WC REST `meta_data`, CSV import and CRUD writes store what they are given. An object form field's `validate` runs only on its form; REST, imports, meta and order CRUD writes store what they are given, and so does a Custom Fields box for a key without a leading underscore: core's on a post type that supports `custom-fields`, orders in the posts table included, and WooCommerce's on the order tables' edit screen. WC-rendered controls carry WooCommerce's markup, and the row printed around a custom-type or `checkboxes` field on a WooCommerce settings screen or the product data panel imitates it. On a WooCommerce settings screen a field's `render` callable needs a type WooCommerce does not draw: on one of its own types WooCommerce draws its control and the callable never runs. The framework guarantees markup only for `Forms::render_control()`. `OptionsPage` and `ProductDataTab` report a rejected field in the page's notices, not beside the field, which after the redirect shows its stored value rather than the rejected input. `ObjectForm` reports it as a one-shot user notice through the plugin's `AdminNotices`, shown in the post editor after the save, or else on the user's next admin screen of the site it was saved on, the network and user admin counting as the main site's; a newer rejection of the same field, on any object, replaces it, and a rejection whose notice fails to save goes unreported. On the profile and in the block editor, which never validate the form, a number the browser cannot parse posts as empty and clears the stored value. With WooCommerce's beta `settings-ui` feature on, the sections a framework tab defines stay classic, and a framework section registered on any tab renders in its Settings UI, which saves through the same form post. A field the Settings UI's schema refuses, such as a `checkboxes` or custom-type field holding an array or a radio listing disabled choices, renders that section classic, with a `_doing_it_wrong` notice under `WP_DEBUG`; a field it has no control for, such as one of those types holding no array or WooCommerce's `color`, makes the page fail to render and offer WooCommerce's "Use classic settings" link, under which every field saves. On a WooCommerce settings screen a cleared checkbox or multiselect posts nothing, so one the section does not render at save is stored cleared. 5. Partial bootstrap package: if `load.php` exists but a file it requires is missing, consumer-a's entry, which requires it inside its one `try`, shows the build notice, or rethrows in local and development environments, while an entry that requires it outside a `try` fatals at include; the uninstall shim keeps the data on a web request, while WP-CLI fails and keeps the files. On PHP 7.4 a failed `require` is fatal at include either way. A missing `vendor-prefixed/` unregisters the updater. Core and woocommerce print their boot and migration notices through bootstrap's `Requirements`, so a plugin that boots them without requiring `load.php` fatals when one of those notices renders. Update offers come only from WordPress's own plugin update check. While its wordpress.org request fails, the check offers no Update URI release, so a new release waits for a later check that succeeds. A plugin active only on sites other than the main one is never offered a release, because WordPress checks only on the main site and in the network admin. 6. Deactivation and recovery mode unload safeguards, as for any plugin. Plugins whose data would leak (IC) hide it at the data level. 7. Scheduling and one-shot notices are best effort. Either scheduler can run an invocation twice or not at all, and work advances only with site traffic, Action Scheduler's admin dispatch, or a system cron or WP-CLI runner. A database write that keeps failing keeps Action Scheduler's bulk cancel looping until the request's time limit. On hosts that replace WP-Cron's store, the WP-Cron backend still reads only the `cron` option, so it may not see the host's events. Action Scheduler compares hook names and arguments under the database collation, so identities that differ only in letter case are one there. Action Scheduler stores a string argument holding invalid UTF-8 with those bytes replaced, which its callbacks then receive, while WP-Cron's write of it fails, because WordPress refuses invalid UTF-8 in the options table. Two concurrent renders can both show a one-shot notice, a request that dies after output shows it again, and a block-editor delivery whose response the editor never receives, as when the user leaves during it, loses its one-shots. Block-editor screens hide classic notices: the post editor fetches the site, network and user one-shots that name no screens as it loads and after each save that is not an autosave, while persistent and screen-limited notices wait for a classic screen, as every notice does on the other block editors. The post editor shows a notice's markup through core/notices' `__unstableHTML` option, which a WordPress release may change or remove. It reads each notice aloud through `wp.a11y.speak()`, which drops text between angle brackets, so a literal angle-bracketed placeholder in a message goes unspoken. A dismissal, or a one-shot's showing, applies to the notice's id rather than to the copy the page showed, so a notice saved again under that id after the page showed it is deleted or hidden too. Two writes to one scope's notices, or to one viewer's dismissals, at the same moment can lose one of them: a notice added then never shows, a dismissal is undone, or a consumed one-shot shows once more. A notice whose write fails is lost, and logged only when the write throws. diff --git a/composer.lock b/composer.lock index 0475c73..4f778f3 100644 --- a/composer.lock +++ b/composer.lock @@ -2470,16 +2470,16 @@ }, { "name": "phpunit/phpunit", - "version": "13.4.6", + "version": "13.4.7", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "4821376960d0e1e5a7b7b700955ebcab33ecd71b" + "reference": "e6458d44279e3b4fc492d4c41fe8e677efed7a2c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/4821376960d0e1e5a7b7b700955ebcab33ecd71b", - "reference": "4821376960d0e1e5a7b7b700955ebcab33ecd71b", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/e6458d44279e3b4fc492d4c41fe8e677efed7a2c", + "reference": "e6458d44279e3b4fc492d4c41fe8e677efed7a2c", "shasum": "" }, "require": { @@ -2493,7 +2493,7 @@ "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=8.4.1", - "phpunit/php-code-coverage": "^14.5.0", + "phpunit/php-code-coverage": "^14.5.2", "phpunit/php-file-iterator": "^7.0.2", "phpunit/php-invoker": "^7.0.0", "phpunit/php-text-template": "^6.0.1", @@ -2509,7 +2509,7 @@ "sebastian/object-enumerator": "^8.1.0", "sebastian/recursion-context": "^8.0.1", "sebastian/type": "^7.0.2", - "sebastian/version": "^7.0.1", + "sebastian/version": "^7.1.0", "staabm/side-effects-detector": "^1.0.5" }, "bin": [ @@ -2550,7 +2550,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/13.4.6" + "source": "https://github.com/sebastianbergmann/phpunit/tree/13.4.7" }, "funding": [ { @@ -2558,8 +2558,8 @@ "type": "other" } ], - "time": "2026-11-02T07:12:28+00:00", - "published-time": "2026-11-02T07:13:08+00:00" + "time": "2026-11-13T11:10:10+00:00", + "published-time": "2026-11-13T11:11:23+00:00" }, { "name": "roave/security-advisories", @@ -2567,12 +2567,12 @@ "source": { "type": "git", "url": "https://github.com/Roave/SecurityAdvisories.git", - "reference": "9fb1049dda127f1732cac541e68f8e490db77825" + "reference": "57a2e6c5a84ea71f685b5b586e6ef32ba7bcf5cc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/9fb1049dda127f1732cac541e68f8e490db77825", - "reference": "9fb1049dda127f1732cac541e68f8e490db77825", + "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/57a2e6c5a84ea71f685b5b586e6ef32ba7bcf5cc", + "reference": "57a2e6c5a84ea71f685b5b586e6ef32ba7bcf5cc", "shasum": "" }, "conflict": { @@ -2708,7 +2708,7 @@ "clickstorm/cs-seo": ">=6,<6.8|>=7,<7.5|>=8,<8.4|>=9,<9.3", "co-stack/fal_sftp": "<0.2.6", "cockpit-hq/cockpit": "<=2.14", - "code16/sharp": "<9.22.5", + "code16/sharp": "<9.23|>=10.0.0.0-beta1,<10.0.0.0-beta4", "codeception/codeception": "<3.1.3|>=4,<4.1.22", "codeigniter/framework": "<3.1.10", "codeigniter4/framework": "<4.7.4", @@ -3052,7 +3052,7 @@ "lara-zeus/artemis": ">=1,<=1.0.6", "lara-zeus/dynamic-dashboard": ">=3,<=3.0.1", "laravel/fortify": "<1.11.1", - "laravel/framework": "<12.69|>=13,<13.30", + "laravel/framework": "<12.71|>=13,<13.40", "laravel/laravel": ">=5.4,<5.4.22", "laravel/passport": ">=13,<13.7.1", "laravel/pulse": "<1.3.1", @@ -3164,7 +3164,7 @@ "nategood/httpful": "<1", "neoan3-apps/template": "<1.1.1", "neorazorx/facturascripts": "<2022.04", - "neos/flow": ">=1,<1.0.4|>=1.1,<1.1.1|>=2,<2.0.1|>=2.3,<2.3.16|>=3,<3.0.12|>=3.1,<3.1.10|>=3.2,<3.2.13|>=3.3,<3.3.13|>=4,<4.0.6", + "neos/flow": "<8.3.22|>=8.4,<8.4.7|>=9,<9.0.10|>=9.1,<9.1.5|>=9.2,<9.2.3", "neos/form": ">=1.2,<4.3.3|>=5,<5.0.9|>=5.1,<5.1.3", "neos/media-browser": "<7.3.19|>=8,<8.0.16|>=8.1,<8.1.11|>=8.2,<8.2.11|>=8.3,<8.3.9", "neos/neos": ">=1.1,<1.1.3|>=1.2,<1.2.13|>=2,<2.0.4|>=2.3,<3.0.20|>=3.1,<3.1.18|>=3.2,<3.2.14|>=3.3,<5.3.10|>=7,<7.0.9|>=7.1,<7.1.7|>=7.2,<7.2.6|>=7.3,<7.3.4|>=8,<8.0.2", @@ -3737,8 +3737,8 @@ "type": "tidelift" } ], - "time": "2026-11-12T14:42:16+00:00", - "published-time": "2026-11-12T14:42:22+00:00" + "time": "2026-11-14T00:11:20+00:00", + "published-time": "2026-11-14T00:11:25+00:00" }, { "name": "sebastian/cli-parser", diff --git a/package-lock.json b/package-lock.json index 857b320..cb51f6d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3010,9 +3010,9 @@ } }, "node_modules/@isaacs/nonrecursive-glob-match": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@isaacs/nonrecursive-glob-match/-/nonrecursive-glob-match-1.0.3.tgz", - "integrity": "sha512-SI7kyKHq4XXDRozw+r9GlRZkia1ZkFrpLW98r2IC2Bpr8YkFGE3fvjzmxCG0x78A++69HmDNrDvSMYBZmHGpKA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@isaacs/nonrecursive-glob-match/-/nonrecursive-glob-match-1.1.0.tgz", + "integrity": "sha512-GPm/Dbvk5RLmhRnxXIa7nTpK0xtl4E0wj0MslsuNiJ1FIROU+2+9lgUC/dgF7Ey4QCWhfEsgxilMRMCdHPfDfw==", "dev": true, "license": "BlueOak-1.0.0" }, @@ -5423,13 +5423,13 @@ } }, "node_modules/@playwright/test": { - "version": "1.64.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.1.tgz", - "integrity": "sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==", + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.65.0.tgz", + "integrity": "sha512-iGURLGuKDLakSEl7Pgfv8h7f+XLT+V861Wfhz0zlEl5nyFuKUopdkFBPKqTllTUp06aOwPuH8iuUEb3yp3MpbQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.64.1" + "playwright": "1.65.0" }, "bin": { "playwright": "cli.js" @@ -9548,9 +9548,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001822", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001822.tgz", - "integrity": "sha512-Y3fvWQWzc69dlCyr07tuNrEhotv+db5FpMSPD3slZnkPKwRNOKwFBz2dP966XUmWMXLTOt+YShkFoQoom3U7hg==", + "version": "1.0.30001823", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001823.tgz", + "integrity": "sha512-TQ5s/djDjNk1axreUW+4rULH+BmdCMLiJkhaXnO5wqivG7tITkSUj4ItIdzqdZHKaXcHAV8dhH3JxOM7/7bwqw==", "dev": true, "funding": [ { @@ -9720,9 +9720,9 @@ } }, "node_modules/chrome-launcher": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/chrome-launcher/-/chrome-launcher-1.2.2.tgz", - "integrity": "sha512-sI90u5qf3854bTrWsQMMdsQfSjjMbeSn7QtxVb9zrikf+QgMfHQ71KepHW7ZfixQKrDlAFKb+qnVlSR+xepBzw==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/chrome-launcher/-/chrome-launcher-1.2.3.tgz", + "integrity": "sha512-IpQuNUYnpXXq3VBRJInM7zTZDiAp/cu+zy0X74NjyQtFcZIQBbI64i0PYQO25wNwgmgw2cPBYitVmFz53N11Rg==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -11111,9 +11111,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.477", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.477.tgz", - "integrity": "sha512-PN++F4zWZoVDGWinxMnSCpVqKhqFOJfLXxdU8bYJKudAEsHL/XKVITOKWVbfMt1r276Mi9RDLU9VHx9uKJ4RMQ==", + "version": "1.5.478", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.478.tgz", + "integrity": "sha512-pftG+ma5Lm8O6/p45bhYT6StBN5ziQymaRzmCZwFl8DwWHKPPD93FdOmP1aqftcepolQYvrcyTLa17kE8Yf+jg==", "dev": true, "license": "ISC" }, @@ -13017,13 +13017,13 @@ } }, "node_modules/glob": { - "version": "14.0.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-14.0.3.tgz", - "integrity": "sha512-A1vjIwC3qpdCso45Iw127gnp7sPhqgY8lUQhDrArIs+GbmfaaVPFuoyy7WyLT9EPZCRZ7j7aLdwUhQmHWesEow==", + "version": "14.0.4", + "resolved": "https://registry.npmjs.org/glob/-/glob-14.0.4.tgz", + "integrity": "sha512-mMiwNKoJZ4HAIRVJxotO6LsNRReA30gQwP9E2/p94tfLv+jygCNpKDjhhSL6OLY6LE371EZm5EAf7RpYuUqlsw==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "minimatch": "^11.0.2", + "minimatch": "^11.0.5", "minipass": "^7.1.3", "path-scurry": "^3.0.0" }, @@ -13065,13 +13065,13 @@ } }, "node_modules/glob/node_modules/minimatch": { - "version": "11.0.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-11.0.4.tgz", - "integrity": "sha512-/UJ1dEoVQTjPNDh6SGlUo+ueLMmoHTas4q+8yaAR/5t6nLAIiBDJtoYUh20IYleix7ehiQkgczb84PE3uzHkxg==", + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-11.0.5.tgz", + "integrity": "sha512-snPlVppFcYWfLLFQGhDxUyBcHNtMlWmn3jtj7pEaO+DP8pqL0IvAzXhMYmn1z9ASNtgmwLBdFxSf4tMUB0OTKA==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "@isaacs/nonrecursive-glob-match": "^1.0.3", + "@isaacs/nonrecursive-glob-match": "^1.1.0", "brace-expansion": "^5.0.12" }, "engines": { @@ -17916,13 +17916,13 @@ } }, "node_modules/playwright": { - "version": "1.64.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz", - "integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==", + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.65.0.tgz", + "integrity": "sha512-F3AMWcR44qMBt4kq+6Kb6mX7bjGXshktKFOPULd7reED6ZJWevjbNRqysP7q+AATE9kdPEZBibex1BYsvpVoeA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.64.1" + "playwright-core": "1.65.0" }, "bin": { "playwright": "cli.js" @@ -17932,9 +17932,9 @@ } }, "node_modules/playwright-core": { - "version": "1.64.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz", - "integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==", + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.65.0.tgz", + "integrity": "sha512-vNSKgH6YqtN7cdT/L334zo0a+YwkrpicyrHJlWWPIm/Q+Nn2SWInil01oT0mbu96U4GgoRtCYQD9CzJQgju+lA==", "dev": true, "license": "Apache-2.0", "bin": { @@ -19824,9 +19824,9 @@ "license": "MIT" }, "node_modules/sass": { - "version": "1.107.1", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.107.1.tgz", - "integrity": "sha512-aJ1/LvxOOb04YhmiBjzZn3jVwdHKol+OVhoMV6fFl02fF9T3i+5Mg9l/CnzqtM+NX5Jn5o3lAbs/ZJivTCLHfQ==", + "version": "1.108.1", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.108.1.tgz", + "integrity": "sha512-sIYgSvOPeD9wABwDS/ofNjHASSck+7h3jLkk7wG1NPFL3XeS6vOQjBlAFSmRV09kZr1yt/Tuayt6kKtBbLjHNQ==", "dev": true, "license": "MIT", "dependencies": { @@ -19904,9 +19904,9 @@ "peer": true }, "node_modules/schema-utils": { - "version": "4.8.2", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.8.2.tgz", - "integrity": "sha512-iKlak3OFHF0dgD1nZRvgWQzG+P1TiEcMnLSBtIVBg937z5Jd6WE1c3SyZkXIyCfPHowL35yMsVvIjotm1c1qiw==", + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.8.3.tgz", + "integrity": "sha512-qIfo3f+lRNqnMPgpXSKvUhsT3pY5U+ppmgGr4j8LanFP3W/Ze+xj8DY/dPb5qVu3m6qFIMyMWKVRLNKxrNhxWg==", "dev": true, "license": "MIT", "dependencies": { @@ -19926,7 +19926,7 @@ "url": "https://opencollective.com/webpack" }, "peerDependencies": { - "webpack": "^5.112.0" + "webpack": "^5.0.0" }, "peerDependenciesMeta": { "webpack": { diff --git a/packages/core/README.md b/packages/core/README.md index 24c4266..fd0a8b4 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -194,14 +194,14 @@ final class Greeting implements ComponentInterface { `Assets` registers the plugin's built scripts and styles. Built as `new Assets( $identity )` and defined once in the container, it reads the dependencies and version that wp-scripts writes beside each built file in its `.asset.php`: `register_script()` loads the script's translations from the plugin's `languages/` directory under its slug, and `register_style()` gives the stylesheet the right-to-left variant wp-scripts writes beside it. The `style-{entry}.css` chunk of a `style.scss` import takes its version from its entry's `.asset.php`. Only wp-scripts builds are supported. As a best effort, a file built outside wp-scripts, which has no `.asset.php`, registers with its modification time as its version and, for a script, no dependencies, and a stylesheet without a `-rtl.css` beside it keeps its own file on right-to-left sites. -The plugin's `uninstall.php` hands its footprint, a constant of its composition root, to `Uninstaller::run()`, which unschedules the plugin's jobs on every site and deletes the rest of the footprint on each site that consents. The footprint names the plugin's data by kind: `options`, `option_prefixes`, `transients`, `network_options`, `post_types`, `comment_types`, `meta` and `meta_prefixes` by meta type, `user_meta_site`, `user_meta_global` and their `_prefixes`, `capabilities`, `tables`, `cron_hooks`, `as_hooks` and `as_groups`. A site consents through an option that holds `yes`, a key holding `yes` in an option's array, or a constant defined as `true`. The file loads the engine as the main file does, builds the plugin's identity from the root's constants, and on a site below the floors or with an incomplete build it keeps the data: +The plugin's `uninstall.php` hands its footprint, a constant of its composition root, to `Uninstaller::run()`, which unschedules the plugin's jobs on every site and deletes the rest of the footprint on each site that consents. The footprint names the plugin's data by kind: `options`, `option_prefixes`, `transients`, `network_options`, `post_types`, `comment_types`, `meta` and `meta_prefixes` by meta type, `user_meta_site`, `user_meta_global` and their `_prefixes`, `capabilities`, `tables`, `cron_hooks`, `as_hooks` and `as_groups`. A site consents through an option that holds `yes` or a constant defined as `true`. The file loads the engine as the main file does, builds the plugin's identity from the root's constants, and on a site below the floors or with an incomplete build it keeps the data: ```php public const array FOOTPRINT = array( 'option_prefixes' => array( self::PREFIX . '_' ), 'user_meta_site_prefixes' => array( self::PREFIX . '_' ), 'user_meta_global_prefixes' => array( self::PREFIX . '_' ), - 'network_options' => array( self::PREFIX . '_network_notices' ), + 'network_options' => array( 'acme_pc_network_notices' ), 'cron_hooks' => array( 'acme_persona_core/migration/run' ), 'as_groups' => array( self::PREFIX ), ); @@ -212,10 +212,7 @@ Uninstaller::run( $acme_pc_identity, $acme_pc_footprint, array( - 'consent' => array( - 'option' => $acme_pc_consent, - 'key' => 'delete_data', - ), + 'consent' => array( 'option' => $acme_pc_consent ), ) ); ``` diff --git a/packages/core/src/Uninstall/Uninstaller.php b/packages/core/src/Uninstall/Uninstaller.php index 9f3c9b4..5776e0b 100644 --- a/packages/core/src/Uninstall/Uninstaller.php +++ b/packages/core/src/Uninstall/Uninstaller.php @@ -14,7 +14,7 @@ * @version 2.0.0 * * @phpstan-type Footprint array{cron_hooks?: list, as_hooks?: list, as_groups?: list, options?: list, option_prefixes?: list, transients?: list, post_types?: list, comment_types?: list, meta?: array>, meta_prefixes?: array>, user_meta_site?: list, user_meta_site_prefixes?: list, user_meta_global?: list, user_meta_global_prefixes?: list, network_options?: list, capabilities?: list, tables?: list} - * @phpstan-type Args array{consent?: array{option?: string, key?: string, constant?: string}, meta_types?: array, cache_groups?: list}>, cleanup?: callable(array{scope: 'site'|'network', site_id: int, plugin_file: string}): void} + * @phpstan-type Args array{consent?: array{option?: string, constant?: string}, meta_types?: array, cache_groups?: list}>, cleanup?: callable(array{scope: 'site'|'network', site_id: int, plugin_file: string}): void} */ final class Uninstaller { // region FIELDS AND CONSTANTS @@ -51,7 +51,7 @@ final class Uninstaller { * * @phpstan-param array> $footprint * @phpstan-param array{meta: array>, meta_prefixes: array>} $meta_footprint - * @phpstan-param array{consent: array{option: string, key: string, constant: string}, meta_types: array, cache_groups: list}>, cleanup: (callable(array{scope: 'site'|'network', site_id: int, plugin_file: string}): void)|null} $args + * @phpstan-param array{consent: array{option: string, constant: string}, meta_types: array, cache_groups: list}>, cleanup: (callable(array{scope: 'site'|'network', site_id: int, plugin_file: string}): void)|null} $args * * @param PluginIdentity $identity The plugin's identity. * @param array $footprint The names and prefixes of every kind but the meta kinds, by kind. @@ -82,7 +82,7 @@ protected function __construct( * * @param PluginIdentity $identity The plugin's identity. * @param array $footprint The plugin's stored data by kind. - * @param array $args Optional. The consent (an option, its key in a grouped row, or a constant), the meta types the meta kinds may name beside post, term and comment, and the cleanup. Default is none, which keeps the footprint. + * @param array $args Optional. The consent (an option or a constant), the meta types the meta kinds may name beside post, term and comment, and the cleanup. Default is none, which keeps the footprint. * * @throws RuntimeException Thrown when a web request runs on a large network, before any change, or when a read or delete, a site's pass or the cleanup failed, once every site was visited. * @@ -96,7 +96,6 @@ public static function run( PluginIdentity $identity, array $footprint, array $a ); $args['consent'] += array( 'option' => '', - 'key' => '', 'constant' => '', ); @@ -234,11 +233,7 @@ protected function uninstall_site(): void { \error_log( '[' . plugin_basename( $this->identity->file ) . '] Uninstall could not unschedule: ' . \implode( '; ', $unscheduling ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- No logger exists while a plugin is uninstalled. } - $consent = get_option( $this->args['consent']['option'] ); - if ( '' !== $this->args['consent']['key'] ) { - $consent = \is_array( $consent ) ? $consent[ $this->args['consent']['key'] ] ?? null : null; - } - if ( 'yes' !== $consent && ! self::is_constant_true( $this->args['consent']['constant'] ) ) { + if ( 'yes' !== get_option( $this->args['consent']['option'] ) && ! self::is_constant_true( $this->args['consent']['constant'] ) ) { return; } diff --git a/packages/settings/README.md b/packages/settings/README.md index bdfc77e..8730b91 100644 --- a/packages/settings/README.md +++ b/packages/settings/README.md @@ -65,7 +65,7 @@ return ( require __DIR__ . '/vendor/ahegyes/wordpress-configs/php/php-scoper/sco ## Usage -An `OptionsPage` stores each tab's fields in one option through the Settings API, and an `ObjectForm` stores each field on the edited object through a store. The plugin's main file requires `load.php` from bootstrap's prefixed copy, then its autoloader once the floor check passes: +An `OptionsPage` stores each field in its own option through the Settings API, and an `ObjectForm` stores each field on the edited object through a store. The plugin's main file requires `load.php` from bootstrap's prefixed copy, then its autoloader once the floor check passes: ```php use Acme\PersonaSettings\Scoped\DeepWebSolutions\Framework\Bootstrap\Requirements; @@ -116,7 +116,7 @@ add_action( ); ``` -A definition object declares the page and its fields as data. A field's `validate` callback returns the value to store, or a `WP_Error` whose message the page shows while the stored value stays: +A definition object declares the page and, apart, each tab's fields as data, so the page asks only for the fields of the tab it shows or saves. Each field is stored in the option its id names, which must start with the plugin's prefix and an underscore, since the page's capability writes it; the page leaves out any other field and reports it through `_doing_it_wrong()`. A number field is held to the `min`, `max` and `step` in its `custom_attributes`, and one with a `step` states its `min` too, from which both the browser and the save count the steps. A field's `validate` callback returns the value to store, or a `WP_Error` whose message the page shows while the stored value stays. Another plugin adds tabs through the `{root}/options_page/tabs` filter and their fields, or fields of an existing tab, through `{root}/options_page/fields`: ```php final class Settings implements OptionsPageDefinitionInterface { @@ -131,22 +131,8 @@ final class Settings implements OptionsPageDefinitionInterface { 'menu_title' => __( 'Acme Persona Settings', 'acme-persona-settings' ), 'tabs' => array( array( - 'slug' => 'general', - 'title' => __( 'General', 'acme-persona-settings' ), - 'option' => self::PREFIX . '_settings', - 'fields' => array( - array( - 'type' => 'number', - 'id' => 'limit', - 'title' => __( 'Limit', 'acme-persona-settings' ), - 'default' => '10', - 'custom_attributes' => array( - 'min' => '1', - 'step' => '1', - ), - 'validate' => array( $this, 'validate_limit' ), - ), - ), + 'slug' => 'general', + 'title' => __( 'General', 'acme-persona-settings' ), ), ), ); @@ -154,10 +140,23 @@ final class Settings implements OptionsPageDefinitionInterface { // ... - public function validate_limit( mixed $raw ): int|\WP_Error { - $limit = \filter_var( $raw, \FILTER_VALIDATE_INT, array( 'options' => array( 'min_range' => 1 ) ) ); - - return false === $limit ? new \WP_Error( 'acme_ps_limit', __( 'Enter a whole number of at least 1.', 'acme-persona-settings' ) ) : $limit; + #[\Override] + public function get_tab_fields( string $page_id, string $tab_slug ): array { + return match ( $tab_slug ) { + 'general' => array( + array( + 'type' => 'number', + 'id' => 'acme_ps_limit', + 'title' => __( 'Limit', 'acme-persona-settings' ), + 'default' => '10', + 'custom_attributes' => array( + 'min' => '1', + 'step' => '1', + ), + ), + ), + default => array(), + }; } // ... @@ -178,7 +177,7 @@ final class Details implements ObjectFormDefinitionInterface { 'fields' => array( array( 'type' => 'text', - 'id' => '_' . Settings::PREFIX . '_subtitle', + 'id' => '_acme_ps_subtitle', 'title' => __( 'Subtitle', 'acme-persona-settings' ), ), ), diff --git a/packages/settings/src/AdminPage/OptionsPage.php b/packages/settings/src/AdminPage/OptionsPage.php index 41a9c3b..bcc8801 100644 --- a/packages/settings/src/AdminPage/OptionsPage.php +++ b/packages/settings/src/AdminPage/OptionsPage.php @@ -9,16 +9,16 @@ \defined( 'ABSPATH' ) || exit; /** - * Adds a submenu page whose tabs each edit one option row through the Settings API. + * Adds a submenu page whose tabs edit their fields through the Settings API, each field stored in the option its id names. * - * Each tab's form posts to options.php under a settings group named after its option, so WordPress checks the nonce and the page's capability before the page sanitizes the submission. + * Each tab's form posts to options.php under the settings group {page id}_{tab slug}, so WordPress checks the nonce and the page's capability before the page sanitizes the submission. Because a field can name any option, which the page's capability then writes, a field id must start with the plugin's prefix. * * @since 2.0.0 * @version 2.0.0 * * @phpstan-import-type Field from Forms * @phpstan-import-type FieldRow from Forms - * @phpstan-type Tab array{slug: string, title: string, option: string, fields: list} + * @phpstan-type Tab array{slug: string, title: string} * @phpstan-type Page array{parent: string, capability: string, page_title: string, menu_title: string, tabs: list} */ final class OptionsPage { @@ -34,6 +34,26 @@ final class OptionsPage { */ protected ?array $page = null; + /** + * The fields of each tab asked for, by the tab's slug, filtered and without the fields whose ids the page refuses. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @var array> + */ + protected array $fields = array(); + + /** + * The fields of the tab the current options.php request saves, by id, which register_settings() keeps for their sanitization. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @var array + */ + protected array $posted = array(); + // endregion // region MAGIC METHODS @@ -44,7 +64,7 @@ final class OptionsPage { * @since 2.0.0 * @version 2.0.0 * - * @param PluginIdentity $identity The plugin's identity, whose hook root starts the page's tabs filter. + * @param PluginIdentity $identity The plugin's identity, whose hook root starts the page's filters and whose prefix starts its field ids. * @param string $id The page's menu slug. * @param OptionsPageDefinitionInterface $definition The page's definition. * @@ -93,7 +113,7 @@ public function add_page(): void { } /** - * Registers each tab's option as a setting in a group of its own name and hooks the option's capability and sanitization. + * Registers, on the options.php request that saves one of the page's tabs, that tab's fields as settings of its group, and hooks the group's capability and each field's sanitization. * * @since 2.0.0 * @version 2.0.0 @@ -101,11 +121,25 @@ public function add_page(): void { * @return void */ public function register_settings(): void { - foreach ( \array_unique( \array_column( $this->get_page()['tabs'], 'option' ) ) as $option ) { - register_setting( $option, $option, array( 'type' => 'object' ) ); - add_filter( "option_page_capability_$option", array( $this, 'get_capability' ) ); - add_filter( "pre_update_option_$option", array( $this, 'sanitize_submission' ), 10, 3 ); + if ( 'options.php' !== ( $GLOBALS['pagenow'] ?? '' ) ) { + return; } + + // WordPress core reads option_page from the request, not only the body, and trims it, so the same reading decides whether the submission is the page's. + $group = \is_string( $_REQUEST['option_page'] ?? null ) ? sanitize_text_field( wp_unslash( $_REQUEST['option_page'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- options.php verifies the nonce before it saves. + $tab = \array_find( $this->get_page()['tabs'], fn ( array $tab ): bool => $group === $this->get_group( $tab['slug'] ) ); + if ( \is_null( $tab ) ) { + return; + } + + foreach ( $this->get_fields( $tab['slug'] ) as $field ) { + if ( isset( $field['id'] ) ) { + $this->posted[ $field['id'] ] = $field; + register_setting( $group, $field['id'], array( 'type' => \in_array( $field['type'], array( 'multiselect', 'checkboxes' ), true ) ? 'array' : 'string' ) ); + add_filter( "pre_update_option_{$field['id']}", array( $this, 'sanitize_submission' ), 10, 3 ); + } + } + add_filter( "option_page_capability_$group", array( $this, 'get_capability' ) ); } /** @@ -121,41 +155,31 @@ public function get_capability(): string { } /** - * Returns the row to store for the option's own options.php submission, in which only the accepted fields change. + * Returns the value to store for a field of the posted tab, or its stored value where the form posted nothing for it or the field rejects it. * - * Other writes of the option pass through unchanged. A rejected field keeps its stored value and adds a settings error that names it. + * A rejection adds a settings error that names the field. * * @since 2.0.0 * @version 2.0.0 * - * @param mixed $value The submitted row, unslashed by options.php, or null when the form posted none of its fields. - * @param mixed $old_value The stored row. - * @param string $option The option's name. + * @param mixed $value The submitted value, unslashed by options.php, or null when the form posted none. + * @param mixed $old_value The stored value. + * @param string $option The option's name, which is the field's id. * * @return mixed */ public function sanitize_submission( mixed $value, mixed $old_value, string $option ): mixed { - // WordPress core reads option_page from the request, not only the body, and trims it, so the same reading decides whether the submission is this option's. - $option_page = \is_string( $_REQUEST['option_page'] ?? null ) ? sanitize_text_field( wp_unslash( $_REQUEST['option_page'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- options.php verifies the nonce before it saves. - if ( 'options.php' !== ( $GLOBALS['pagenow'] ?? '' ) || $option_page !== $option ) { + $field = $this->posted[ $option ] ?? null; + if ( \is_null( $field ) ) { return $value; } - $fields = array(); - foreach ( $this->get_page()['tabs'] as $tab ) { - if ( $option === $tab['option'] ) { - $fields = \array_merge( $fields, $tab['fields'] ); - } + $result = Forms::sanitize_fields( array( $field ), \is_null( $value ) ? array() : array( $option => $value ) ); + if ( isset( $result['errors'][ $option ] ) ) { + add_settings_error( $option, $option, esc_html( Forms::get_rejection_message( Forms::get_title( $field ), $result['errors'][ $option ] ) ) ); } - $result = Forms::sanitize_fields( $fields, \is_array( $value ) ? $value : array() ); - foreach ( $fields as $field ) { - if ( isset( $field['id'], $result['errors'][ $field['id'] ] ) ) { - add_settings_error( $option, $field['id'], esc_html( Forms::get_rejection_message( Forms::get_title( $field ), $result['errors'][ $field['id'] ] ) ) ); - } - } - - return array() === $result['values'] ? $old_value : \array_replace( \is_array( $old_value ) ? $old_value : array(), $result['values'] ); + return \array_key_exists( $option, $result['values'] ) ? $result['values'][ $option ] : $old_value; } /** @@ -190,18 +214,29 @@ public function render_page(): void { echo ''; } - $option = $tab['option']; - $stored = get_option( $option ); - foreach ( Forms::get_rows( $tab['fields'], \is_array( $stored ) ? $stored : array(), $option ) as $row ) { - if ( isset( $row['label_for'] ) ) { // Core prints the label through esc_attr(), which keeps an entity the control's id encodes. - $row['label_for'] = Forms::escape_attribute( $row['label_for'] ); + $fields = $this->get_fields( $tab['slug'] ); + $ids = \array_column( $fields, 'id' ); + wp_prime_option_caches( $ids ); + $stored = array(); + foreach ( $ids as $id ) { + $value = get_option( $id ); + if ( false !== $value ) { // A field without a stored row shows its default. + $stored[ $id ] = $value; } + } + $rows = Forms::get_rows( $fields, $stored, '' ); + if ( array() === $rows ) { // A tab without a field to save registers no settings group, so options.php would refuse its form. + echo ''; + return; + } + + foreach ( $rows as $row ) { add_settings_field( $row['field']['id'], esc_html( $row['field']['title'] ), array( $this, 'render_field' ), $this->id, 'default', $row ); } echo '
'; - settings_fields( $option ); + settings_fields( $this->get_group( $tab['slug'] ) ); echo ''; do_settings_fields( $this->id, 'default' ); echo ''; @@ -240,7 +275,7 @@ protected function get_page(): array { $page = $this->definition->get_options_page( $this->id ); /** - * Filters the tabs of the options page, so that other plugins can add tabs or fields. + * Filters the tabs of the options page, so that other plugins can add tabs, whose fields come through the page's fields filter. * * @since 2.0.0 * @version 2.0.0 @@ -255,5 +290,66 @@ protected function get_page(): array { return $this->page; } + /** + * Returns the fields of one of the page's tabs, evaluating and filtering them on first use, without a field whose id does not start with the plugin's prefix or is not a valid helper id, which it reports through _doing_it_wrong(). + * + * @since 2.0.0 + * @version 2.0.0 + * + * @param string $tab_slug The tab's slug. + * + * @return list + */ + protected function get_fields( string $tab_slug ): array { + if ( ! isset( $this->fields[ $tab_slug ] ) ) { + /** + * Filters the fields of one tab of the options page, so that other plugins can add fields, whose ids start with the plugin's prefix. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @param list $fields The tab's fields. + * @param string $tab_slug The tab's slug. + * @param string $page_id The page's menu slug. + */ + $fields = apply_filters( "{$this->identity->hook_root}/options_page/fields", $this->definition->get_tab_fields( $this->id, $tab_slug ), $tab_slug, $this->id ); + + $this->fields[ $tab_slug ] = array(); + foreach ( $fields as $field ) { + $id = $field['id'] ?? null; + try { + if ( ! \is_null( $id ) ) { + Forms::check_helper_id( $id ); + } + $kept = \is_null( $id ) || \str_starts_with( $id, "{$this->identity->prefix}_" ); + } catch ( InvalidArgumentException ) { + $kept = false; + } + + if ( $kept ) { + $this->fields[ $tab_slug ][] = $field; + } else { + _doing_it_wrong( __METHOD__, esc_html( "Options page '$this->id' leaves out field '$id', whose id is not '{$this->identity->prefix}_' followed by lowercase letters, digits, underscores and hyphens." ), '2.0.0' ); + } + } + } + + return $this->fields[ $tab_slug ]; + } + + /** + * Returns the settings group of one of the page's tabs, which names its form's nonce and capability filter. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @param string $tab_slug The tab's slug. + * + * @return string + */ + protected function get_group( string $tab_slug ): string { + return "{$this->id}_$tab_slug"; + } + // endregion } diff --git a/packages/settings/src/AdminPage/OptionsPageDefinitionInterface.php b/packages/settings/src/AdminPage/OptionsPageDefinitionInterface.php index 143a38b..29e72f8 100644 --- a/packages/settings/src/AdminPage/OptionsPageDefinitionInterface.php +++ b/packages/settings/src/AdminPage/OptionsPageDefinitionInterface.php @@ -2,15 +2,18 @@ namespace DeepWebSolutions\Framework\Settings\AdminPage; +use DeepWebSolutions\Framework\Settings\Forms; + \defined( 'ABSPATH' ) || exit; /** - * Defines an options page, which asks for its definition once, on first use after init, so that its labels are translated in the viewer's locale. + * Defines an options page, which asks for its definition once, on first use after init, so that its labels are translated in the viewer's locale, and for a tab's fields only when that tab shows or saves. * * @since 2.0.0 * @version 2.0.0 * * @phpstan-import-type Page from OptionsPage + * @phpstan-import-type Field from Forms */ interface OptionsPageDefinitionInterface { /** @@ -24,4 +27,17 @@ interface OptionsPageDefinitionInterface { * @return Page */ public function get_options_page( string $page_id ): array; + + /** + * Returns the fields of one of the page's tabs, each stored in the option its id names, which must not throw. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @param string $page_id The page's menu slug. + * @param string $tab_slug The tab's slug. + * + * @return list + */ + public function get_tab_fields( string $page_id, string $tab_slug ): array; } diff --git a/packages/settings/src/Forms.php b/packages/settings/src/Forms.php index 49dd4af..e0dcf2a 100644 --- a/packages/settings/src/Forms.php +++ b/packages/settings/src/Forms.php @@ -137,7 +137,7 @@ public static function sanitize_fields( array $fields, array $submitted ): array 'textarea' => self::is_text( $raw ) ? \str_replace( '<', '<', sanitize_textarea_field( (string) $raw ) ) : self::reject( 'invalid_value' ), 'email' => self::sanitize_email( $raw ), 'url' => self::sanitize_url( $raw ), - 'number' => self::sanitize_number( $raw ), + 'number' => self::sanitize_number( $field, $raw ), 'checkbox' => self::sanitize_checkbox( $raw ), 'select', 'radio' => self::sanitize_choice( $field, $raw ), 'multiselect', 'checkboxes' => self::sanitize_choices( $field, $raw ), @@ -288,7 +288,7 @@ public static function escape_attribute( string $value ): string { * * @param list $fields The fields. * @param array $values The stored values, keyed by field id. - * @param string $prefix The prefix of the names and HTML ids, such as an option or a form id. + * @param string $prefix The prefix of the names and HTML ids, such as a form id, or an empty string where each field posts under its own id. * * @return list */ @@ -304,8 +304,8 @@ public static function get_rows( array $fields, array $values, string $prefix ): $row = array( 'field' => $field, 'value' => \array_key_exists( $field['id'], $values ) ? $values[ $field['id'] ] : ( $field['default'] ?? '' ), - 'name' => "{$prefix}[{$field['id']}]", - 'id' => "$prefix.{$field['id']}", + 'name' => '' === $prefix ? $field['id'] : "{$prefix}[{$field['id']}]", + 'id' => '' === $prefix ? $field['id'] : "$prefix.{$field['id']}", ); if ( ! self::is_group( $field ) ) { $row['label_for'] = $row['id']; @@ -693,18 +693,19 @@ protected static function sanitize_url( mixed $raw ): string|\WP_Error { } /** - * Returns the submission as a trimmed number string in HTML's floating-point grammar, or a WP_Error for anything else that is not empty. + * Returns the submission as the canonical text of a number, or a WP_Error for anything else that is not empty, and for a number below the field's min, above its max or off the grid its step lays from its min, or from zero. * - * A number input renders any other numeric string, such as +1 or 1., as empty, so the next save would clear it. + * The attributes are those the control renders with, read as the browser reads them, so an unparseable min or max is ignored and a step that is neither any nor a positive number counts as 1. The grid is checked on the decimal digits, which floating point cannot hold for a step such as 0.01, so a number whose digits at the grid's precision leave PHP's integer range is rejected. * * @since 2.0.0 * @version 2.0.0 * - * @param mixed $raw The unslashed submission. + * @param Field $field The field. + * @param mixed $raw The unslashed submission. * * @return string|\WP_Error */ - protected static function sanitize_number( mixed $raw ): string|\WP_Error { + protected static function sanitize_number( array $field, mixed $raw ): string|\WP_Error { if ( \is_float( $raw ) && ! \is_finite( $raw ) ) { return self::reject( 'invalid_number' ); } @@ -712,12 +713,116 @@ protected static function sanitize_number( mixed $raw ): string|\WP_Error { return self::reject( 'invalid_value' ); } - $number = \trim( (string) $raw ); - if ( '' === $number ) { + $text = \trim( (string) $raw ); + if ( '' === $text ) { return ''; } - return 1 === \preg_match( '/\A-?(?:\d+(?:\.\d+)?|\.\d+)(?:[eE][-+]?\d+)?\z/', $number ) && \is_finite( (float) $number ) ? $number : self::reject( 'invalid_number' ); + $number = self::to_canonical_number( $text ); + if ( \is_null( $number ) ) { + return self::reject( 'invalid_number' ); + } + + $attributes = self::get_custom_attributes( $field ) + self::DEFAULT_CUSTOM_ATTRIBUTES['number']; + $min = self::to_canonical_number( $attributes['min'] ?? '' ); + $max = self::to_canonical_number( $attributes['max'] ?? '' ); + if ( ! \is_null( $min ) && self::compare_numbers( $number, $min ) < 0 ) { + return self::reject( 'number_below_min', $min ); + } + if ( ! \is_null( $max ) && self::compare_numbers( $number, $max ) > 0 ) { + return self::reject( 'number_above_max', $max ); + } + if ( 'any' === \strtolower( $attributes['step'] ) ) { + return $number; + } + + $step = self::to_canonical_number( $attributes['step'] ); + if ( \is_null( $step ) || '0' === $step || \str_starts_with( $step, '-' ) ) { + $step = '1'; + } + + $base = $min ?? '0'; + $places = \max( \array_map( static fn ( string $part ): int => \strlen( \explode( '.', "$part." )[1] ), array( $number, $base, $step ) ) ); + $scaled = array(); + foreach ( array( $number, $base, $step ) as $part ) { + list( $whole, $fraction ) = \explode( '.', "$part." ); + $digits = \ltrim( \ltrim( $whole, '-' ) . \str_pad( $fraction, $places, '0' ), '0' ); + $scaled[] = \filter_var( ( \str_starts_with( $whole, '-' ) ? '-' : '' ) . ( '' === $digits ? '0' : $digits ), \FILTER_VALIDATE_INT ); + } + + // The difference between the number and the grid's origin must stay an integer too, which PHP would turn into a float past its range. + list( $value, $origin, $interval ) = $scaled; + if ( ! \is_int( $value ) || ! \is_int( $origin ) || ! \is_int( $interval ) || ( $origin < 0 ? $value > \PHP_INT_MAX + $origin : $value < \PHP_INT_MIN + $origin ) ) { + return self::reject( 'invalid_number' ); + } + + return 0 === ( $value - $origin ) % $interval ? $number : self::reject( 'number_off_step', $step, $base ); + } + + /** + * Returns a number in HTML's floating-point grammar as its canonical text, without an exponent, a plus sign or padding zeros and with -0 as 0, or null for any other text and for a number a float cannot hold. + * + * The text keeps every digit given, because a float would round them. A number input renders any other numeric text, such as +1 or 1., as empty, so the next save would clear it. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @param string $text The text. + * + * @return string|null + */ + protected static function to_canonical_number( string $text ): ?string { + $text = \trim( $text ); + if ( 1 !== \preg_match( '/\A-?(?:\d+(?:\.\d+)?|\.\d+)(?:[eE][-+]?\d+)?\z/', $text ) || ! \is_finite( (float) $text ) ) { + return null; + } + + list( $mantissa, $exponent ) = \explode( 'e', \strtolower( $text ) ) + array( 1 => '0' ); + list( $whole, $fraction ) = \explode( '.', \ltrim( $mantissa, '-' ) ) + array( 1 => '' ); + $digits = $whole . $fraction; + if ( '' === \trim( $digits, '0' ) ) { + return '0'; + } + if ( 0.0 === (float) $text ) { // Digits a float rounds to zero would take an exponent's worth of zeros to write out. + return null; + } + + $point = \strlen( $whole ) + (int) $exponent; + if ( $point < 1 ) { + $digits = \str_repeat( '0', 1 - $point ) . $digits; + $point = 1; + } + + $digits = \str_pad( $digits, $point, '0' ); + $whole = \ltrim( \substr( $digits, 0, $point ), '0' ); + $fraction = \rtrim( \substr( $digits, $point ), '0' ); + + return ( \str_starts_with( $mantissa, '-' ) ? '-' : '' ) . ( '' === $whole ? '0' : $whole ) . ( '' === $fraction ? '' : ".$fraction" ); + } + + /** + * Returns -1, 0 or 1 as the first canonical number is less than, equal to or greater than the second. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @param string $first The first number, as to_canonical_number() writes it. + * @param string $second The second number, as to_canonical_number() writes it. + * + * @return int + */ + protected static function compare_numbers( string $first, string $second ): int { + $negative = \str_starts_with( $first, '-' ); + if ( \str_starts_with( $second, '-' ) !== $negative ) { + return $negative ? -1 : 1; + } + + list( $first_whole, $first_fraction ) = \explode( '.', \ltrim( $first, '-' ) ) + array( 1 => '' ); + list( $second_whole, $second_fraction ) = \explode( '.', \ltrim( $second, '-' ) ) + array( 1 => '' ); + $places = \max( \strlen( $first_fraction ), \strlen( $second_fraction ) ); + $order = ( \strlen( $first_whole ) <=> \strlen( $second_whole ) ) ?: ( \strcmp( $first_whole . \str_pad( $first_fraction, $places, '0' ), $second_whole . \str_pad( $second_fraction, $places, '0' ) ) <=> 0 ); + + return $negative ? -$order : $order; } /** @@ -826,25 +931,32 @@ protected static function is_disabled_choice( array $field, int|string $key ): b * @since 2.0.0 * @version 2.0.0 * - * @phpstan-param 'invalid_value'|'invalid_choice'|'invalid_email'|'invalid_url'|'invalid_number'|'unknown_type'|'missing_validate' $code + * @phpstan-param 'invalid_value'|'invalid_choice'|'invalid_email'|'invalid_url'|'invalid_number'|'number_below_min'|'number_above_max'|'number_off_step'|'unknown_type'|'missing_validate' $code * - * @param string $code The rejection's code. + * @param string $code The rejection's code. + * @param string ...$values The values the message names, such as the bound a number missed. * * @return \WP_Error */ - protected static function reject( string $code ): \WP_Error { - return new \WP_Error( - $code, - match ( $code ) { - 'invalid_value' => __( 'Enter a valid value.', 'wp-framework' ), - 'invalid_choice' => __( 'Choose one of the available options.', 'wp-framework' ), - 'invalid_email' => __( 'Enter a valid email address, such as name@example.com.', 'wp-framework' ), - 'invalid_url' => __( 'Enter a valid URL, such as https://example.com.', 'wp-framework' ), - 'invalid_number' => __( 'Enter a number, such as 12 or 3.5.', 'wp-framework' ), - 'unknown_type' => __( 'This field cannot be saved because its type is not supported.', 'wp-framework' ), - 'missing_validate' => __( 'This field cannot be saved because nothing validates its value.', 'wp-framework' ), - } - ); + protected static function reject( string $code, string ...$values ): \WP_Error { + $message = match ( $code ) { + 'invalid_value' => __( 'Enter a valid value.', 'wp-framework' ), + 'invalid_choice' => __( 'Choose one of the available options.', 'wp-framework' ), + 'invalid_email' => __( 'Enter a valid email address, such as name@example.com.', 'wp-framework' ), + 'invalid_url' => __( 'Enter a valid URL, such as https://example.com.', 'wp-framework' ), + 'invalid_number' => __( 'Enter a number, such as 12 or 3.5.', 'wp-framework' ), + /* translators: %s: The smallest number the field accepts. */ + 'number_below_min' => __( 'Enter a number of at least %s.', 'wp-framework' ), + /* translators: %s: The largest number the field accepts. */ + 'number_above_max' => __( 'Enter a number of at most %s.', 'wp-framework' ), + /* translators: 1: The step between the numbers the field accepts. 2: The number the steps start from. */ + 'number_off_step' => __( 'Enter a number in steps of %1$s from %2$s.', 'wp-framework' ), + 'unknown_type' => __( 'This field cannot be saved because its type is not supported.', 'wp-framework' ), + 'missing_validate' => __( 'This field cannot be saved because nothing validates its value.', 'wp-framework' ), + }; + + // Only a message that names values goes through sprintf(), so that a translation's literal percent sign cannot throw. + return new \WP_Error( $code, array() === $values ? $message : \sprintf( $message, ...$values ) ); } /** diff --git a/packages/woocommerce/README.md b/packages/woocommerce/README.md index ac4db99..ddef2ab 100644 --- a/packages/woocommerce/README.md +++ b/packages/woocommerce/README.md @@ -151,7 +151,7 @@ final class Packing implements ProductDataTabDefinitionInterface, SectionFieldsD return array( array( 'type' => 'text', - 'id' => self::PREFIX . '_default_box', + 'id' => 'acme_pw_default_box', 'title' => __( 'Default box', 'acme-persona-woocommerce' ), ), array( diff --git a/packages/woocommerce/src/Setting/Fields.php b/packages/woocommerce/src/Setting/Fields.php index 4380498..2b5f29d 100644 --- a/packages/woocommerce/src/Setting/Fields.php +++ b/packages/woocommerce/src/Setting/Fields.php @@ -89,7 +89,9 @@ public static function check_id( string $id ): void { // region HOOKS /** - * Takes ownership of the fields of one of the helper's sections, as WooCommerce's settings filter leaves them, and hooks the row printer to each type among them that WooCommerce does not draw and Forms does. + * Takes ownership of the fields of one of the helper's sections, as WooCommerce's settings filter leaves them, gives each one WooCommerce draws the attributes Forms renders its type with unless it sets them, and hooks the row printer to each type among them that WooCommerce does not draw and Forms does. + * + * WooCommerce prints only a field's own attributes, so a number field without a step would take the browser's step of 1 while its save accepts any. * * @since 2.0.0 * @version 2.0.0 @@ -104,15 +106,19 @@ public function claim( array $settings, string $section_id ): array { return $settings; } - foreach ( $settings as $setting ) { + foreach ( $settings as $index => $setting ) { if ( ! self::is_field( $setting ) ) { continue; } - $this->owned[ $setting['id'] ?? '' ] = $setting; // is_field() has checked the id, which the Field shape leaves optional. if ( self::is_drawn_by_forms( $setting ) ) { add_action( "woocommerce_admin_field_{$setting['type']}", array( $this, 'render_row' ) ); + } elseif ( isset( Forms::DEFAULT_CUSTOM_ATTRIBUTES[ $setting['type'] ] ) ) { + $setting['custom_attributes'] = ( $setting['custom_attributes'] ?? array() ) + Forms::DEFAULT_CUSTOM_ATTRIBUTES[ $setting['type'] ]; + $settings[ $index ] = $setting; } + + $this->owned[ $setting['id'] ?? '' ] = $setting; // is_field() has checked the id, which the Field shape leaves optional. } return $settings; @@ -121,7 +127,7 @@ public function claim( array $settings, string $section_id ): array { /** * Returns the value to store for an owned field's submission, or null, under which WooCommerce keeps the stored value, and any other option's value unchanged. * - * A disabled field is never saved, and a field the form did not post keeps its value, but for a checkbox or multiselect, which WooCommerce's form posts nothing for when cleared, so it is stored cleared. + * A disabled field is never saved, and a field the form did not post keeps its value, but for a checkbox or multiselect, which WooCommerce's form posts nothing for when cleared, so it is stored cleared. A number field without validate goes through Forms' number sanitizer, which holds it to its min, max and step, and any other field WooCommerce draws without validate stores WooCommerce's value. * * @since 2.0.0 * @version 2.0.0 @@ -144,7 +150,7 @@ public function sanitize_option( mixed $value, mixed $option, mixed $raw ): mixe 'multiselect', 'multi_select_countries' => array(), default => null, }; - if ( ! isset( $field['validate'] ) && ! self::is_drawn_by_forms( $field ) ) { + if ( ! isset( $field['validate'] ) && ! self::is_drawn_by_forms( $field ) && 'number' !== $field['type'] ) { $field['validate'] = static fn (): mixed => StoredData::is_storable_data( $value ) ? $value : null; } diff --git a/tests/Fixtures/Settings/acme-options-page.php b/tests/Fixtures/Settings/acme-options-page.php index aa2c191..a28e3db 100644 --- a/tests/Fixtures/Settings/acme-options-page.php +++ b/tests/Fixtures/Settings/acme-options-page.php @@ -2,7 +2,7 @@ /** * Registers the request spec's options page with a plain closure on plugins_loaded. * - * A field's validate and render may name a behavior RecordedDefinition::with_behaviors() lists, which records what it receives in the request's outcome. The spec may also contribute tabs and fields through the page's tabs filter, and update options programmatically on admin_init. + * A field's validate and render may name a behavior RecordedDefinition::with_behaviors() lists, which records what it receives in the request's outcome. The outcome also lists the tabs whose fields the page asked for, and the _doing_it_wrong() calls in place of their notices. The spec may also contribute tabs through the page's tabs filter and fields through its fields filter, and update options programmatically on admin_init. * * @package Acme */ @@ -13,44 +13,55 @@ require_once dirname( __DIR__, 3 ) . '/vendor/autoload.php'; -$acme_with_behaviors = static function ( array $tabs ): array { - foreach ( $tabs as &$tab ) { - $tab['fields'] = RecordedDefinition::with_behaviors( $tab['fields'] ); - } - - return $tabs; -}; +add_filter( 'doing_it_wrong_trigger_error', '__return_false' ); +add_action( + 'doing_it_wrong_run', + static function ( string $function_name, string $message ): void { + $GLOBALS['acme_outcome']['doing_it_wrong'][] = array( $function_name, $message ); + }, + 10, + 2 +); add_action( 'plugins_loaded', - static function () use ( $spec, $acme_with_behaviors ): void { + static function () use ( $spec ): void { $page = new OptionsPage( - new PluginIdentity( __FILE__, $spec['slug'], 'acme' ), + new PluginIdentity( __FILE__, $spec['slug'], $spec['prefix'] ), $spec['id'], - new RecordedDefinition( static fn (): array => array( 'tabs' => $acme_with_behaviors( $spec['page']['tabs'] ) ) + $spec['page'] ) + new RecordedDefinition( + static function ( string $page_id, ?string $tab_slug = null ) use ( $spec ): array { + if ( is_null( $tab_slug ) ) { + return $spec['page']; + } + + $GLOBALS['acme_outcome']['asked'][] = $tab_slug; + + return RecordedDefinition::with_behaviors( $spec['fields'][ $tab_slug ] ?? array() ); + } + ) ); $page->register_hooks(); } ); -if ( isset( $spec['contributed'] ) ) { +$acme_hook_root = str_replace( '-', '_', $spec['slug'] ); +if ( isset( $spec['contributed']['tabs'] ) ) { add_filter( - str_replace( '-', '_', $spec['slug'] ) . '/options_page/tabs', - static function ( array $tabs, string $page_id ) use ( $spec, $acme_with_behaviors ): array { - if ( $spec['id'] !== $page_id ) { - return $tabs; - } - - foreach ( $tabs as &$tab ) { - $tab['fields'] = array_merge( $tab['fields'], $spec['contributed']['fields'][ $tab['slug'] ] ?? array() ); - } - - return $acme_with_behaviors( array_merge( $tabs, $spec['contributed']['tabs'] ?? array() ) ); - }, + "$acme_hook_root/options_page/tabs", + static fn ( array $tabs, string $page_id ): array => $spec['id'] === $page_id ? array_merge( $tabs, $spec['contributed']['tabs'] ) : $tabs, 10, 2 ); } +if ( isset( $spec['contributed']['fields'] ) ) { + add_filter( + "$acme_hook_root/options_page/fields", + static fn ( array $fields, string $tab_slug, string $page_id ): array => $spec['id'] === $page_id ? array_merge( $fields, RecordedDefinition::with_behaviors( $spec['contributed']['fields'][ $tab_slug ] ?? array() ) ) : $fields, + 10, + 3 + ); +} if ( isset( $spec['update'] ) ) { add_action( diff --git a/tests/Fixtures/consumer-a/composer.lock b/tests/Fixtures/consumer-a/composer.lock index 7cba8f6..c933f7c 100644 --- a/tests/Fixtures/consumer-a/composer.lock +++ b/tests/Fixtures/consumer-a/composer.lock @@ -2173,12 +2173,12 @@ "source": { "type": "git", "url": "https://github.com/Roave/SecurityAdvisories.git", - "reference": "9fb1049dda127f1732cac541e68f8e490db77825" + "reference": "57a2e6c5a84ea71f685b5b586e6ef32ba7bcf5cc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/9fb1049dda127f1732cac541e68f8e490db77825", - "reference": "9fb1049dda127f1732cac541e68f8e490db77825", + "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/57a2e6c5a84ea71f685b5b586e6ef32ba7bcf5cc", + "reference": "57a2e6c5a84ea71f685b5b586e6ef32ba7bcf5cc", "shasum": "" }, "conflict": { @@ -2314,7 +2314,7 @@ "clickstorm/cs-seo": ">=6,<6.8|>=7,<7.5|>=8,<8.4|>=9,<9.3", "co-stack/fal_sftp": "<0.2.6", "cockpit-hq/cockpit": "<=2.14", - "code16/sharp": "<9.22.5", + "code16/sharp": "<9.23|>=10.0.0.0-beta1,<10.0.0.0-beta4", "codeception/codeception": "<3.1.3|>=4,<4.1.22", "codeigniter/framework": "<3.1.10", "codeigniter4/framework": "<4.7.4", @@ -2658,7 +2658,7 @@ "lara-zeus/artemis": ">=1,<=1.0.6", "lara-zeus/dynamic-dashboard": ">=3,<=3.0.1", "laravel/fortify": "<1.11.1", - "laravel/framework": "<12.69|>=13,<13.30", + "laravel/framework": "<12.71|>=13,<13.40", "laravel/laravel": ">=5.4,<5.4.22", "laravel/passport": ">=13,<13.7.1", "laravel/pulse": "<1.3.1", @@ -2770,7 +2770,7 @@ "nategood/httpful": "<1", "neoan3-apps/template": "<1.1.1", "neorazorx/facturascripts": "<2022.04", - "neos/flow": ">=1,<1.0.4|>=1.1,<1.1.1|>=2,<2.0.1|>=2.3,<2.3.16|>=3,<3.0.12|>=3.1,<3.1.10|>=3.2,<3.2.13|>=3.3,<3.3.13|>=4,<4.0.6", + "neos/flow": "<8.3.22|>=8.4,<8.4.7|>=9,<9.0.10|>=9.1,<9.1.5|>=9.2,<9.2.3", "neos/form": ">=1.2,<4.3.3|>=5,<5.0.9|>=5.1,<5.1.3", "neos/media-browser": "<7.3.19|>=8,<8.0.16|>=8.1,<8.1.11|>=8.2,<8.2.11|>=8.3,<8.3.9", "neos/neos": ">=1.1,<1.1.3|>=1.2,<1.2.13|>=2,<2.0.4|>=2.3,<3.0.20|>=3.1,<3.1.18|>=3.2,<3.2.14|>=3.3,<5.3.10|>=7,<7.0.9|>=7.1,<7.1.7|>=7.2,<7.2.6|>=7.3,<7.3.4|>=8,<8.0.2", @@ -3343,8 +3343,8 @@ "type": "tidelift" } ], - "time": "2026-11-12T14:42:16+00:00", - "published-time": "2026-11-12T14:42:22+00:00" + "time": "2026-11-14T00:11:20+00:00", + "published-time": "2026-11-14T00:11:25+00:00" }, { "name": "sniccowp/php-scoper-wordpress-excludes", diff --git a/tests/Fixtures/consumer-a/functions.php b/tests/Fixtures/consumer-a/functions.php index e072332..8922740 100644 --- a/tests/Fixtures/consumer-a/functions.php +++ b/tests/Fixtures/consumer-a/functions.php @@ -1,6 +1,6 @@ $value ) { + if ( false === get_option( $key ) && ! add_option( $key, $value, '', true ) ) { + throw new \RuntimeException( "The option $key could not be stored." ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing, never rendered. + } } } diff --git a/tests/Fixtures/consumer-a/src/Options.php b/tests/Fixtures/consumer-a/src/Options.php index 6522312..bede49e 100644 --- a/tests/Fixtures/consumer-a/src/Options.php +++ b/tests/Fixtures/consumer-a/src/Options.php @@ -5,7 +5,7 @@ \defined( 'ABSPATH' ) || exit; /** - * Names the plugin's stored settings and their defaults. + * Names the plugin's stored settings and their defaults, and reads each setting, guarding the stored value. * * @since 1.0.0 * @version 1.0.0 @@ -14,24 +14,37 @@ final class Options { // region FIELDS AND CONSTANTS /** - * The option that holds the plugin's settings: 'yes' under delete_data where the site consents to uninstall removing the plugin's data. + * The option that holds 'yes' where the site consents to uninstall removing the plugin's data. * * @since 1.0.0 * @version 1.0.0 * * @var string */ - public const string SETTINGS = Plugin::PREFIX . '_settings'; + public const string DELETE_DATA = 'acme_ca_delete_data'; /** - * The settings the plugin stores on its first migration, which keep the plugin's data on uninstall. + * The option that holds 'yes' where the site switches the reports on. * * @since 1.0.0 * @version 1.0.0 * - * @var array{delete_data: 'yes'|'no'} + * @var string + */ + public const string REPORTS = 'acme_ca_reports'; + + /** + * The default of each option, in the form the option stores it. + * + * @since 1.0.0 + * @version 1.0.0 + * + * @var array */ - public const array DEFAULTS = array( 'delete_data' => 'no' ); + public const array DEFAULTS = array( + self::DELETE_DATA => 'no', + self::REPORTS => 'no', + ); // endregion @@ -46,4 +59,23 @@ final class Options { protected function __construct() {} // endregion + + // region METHODS + + /** + * Returns an option as a boolean, which reads checkbox values such as 'yes' and 'no'. + * + * @since 1.0.0 + * @version 1.0.0 + * + * @param string $key The option's key. + * + * @return bool + */ + public static function get_bool( string $key ): bool { + $default = self::DEFAULTS[ $key ] ?? null; + return \filter_var( get_option( $key, $default ), \FILTER_VALIDATE_BOOL, \FILTER_NULL_ON_FAILURE ) ?? \filter_var( $default, \FILTER_VALIDATE_BOOL ); + } + + // endregion } diff --git a/tests/Fixtures/consumer-a/src/Plugin.php b/tests/Fixtures/consumer-a/src/Plugin.php index 9465716..23930bf 100644 --- a/tests/Fixtures/consumer-a/src/Plugin.php +++ b/tests/Fixtures/consumer-a/src/Plugin.php @@ -36,7 +36,7 @@ final class Plugin { public const string SLUG = 'acme-consumer-a'; /** - * The prefix of the plugin's options, transients and scheduled jobs. + * The prefix of the names the plugin stores its data under, and its Action Scheduler group. * * @since 1.0.0 * @version 1.0.0 @@ -63,7 +63,7 @@ final class Plugin { ); /** - * The plugin's stored data by kind, which covers every option, transient, meta key and scheduled job under the plugin's prefix. + * The plugin's stored data by kind, which uninstall reads. * * @since 1.0.0 * @version 1.0.0 @@ -75,7 +75,7 @@ final class Plugin { 'meta' => array( 'post' => array( ProductData::NOTE_KEY ) ), 'user_meta_site_prefixes' => array( self::PREFIX . '_' ), 'user_meta_global_prefixes' => array( self::PREFIX . '_' ), - 'network_options' => array( self::PREFIX . '_network_notices' ), + 'network_options' => array( 'acme_ca_network_notices' ), 'cron_hooks' => array( 'acme_consumer_a/migration/run' ), 'as_groups' => array( self::PREFIX ), ); diff --git a/tests/Fixtures/consumer-a/src/Reports/Reports.php b/tests/Fixtures/consumer-a/src/Reports/Reports.php index 5f4eaae..f7b06c3 100644 --- a/tests/Fixtures/consumer-a/src/Reports/Reports.php +++ b/tests/Fixtures/consumer-a/src/Reports/Reports.php @@ -2,7 +2,7 @@ namespace Acme\ConsumerA\Reports; -use Acme\ConsumerA\Plugin; +use Acme\ConsumerA\Options; use Acme\ConsumerA\Scoped\DeepWebSolutions\Framework\Core\CompositeComponentInterface; use Acme\ConsumerA\Scoped\DeepWebSolutions\Framework\Core\ConditionalComponentInterface; @@ -25,7 +25,7 @@ final class Reports implements CompositeComponentInterface, ConditionalComponent */ #[\Override] public static function should_load(): bool { - return 'yes' === get_option( Plugin::PREFIX . '_reports', 'no' ); + return Options::get_bool( Options::REPORTS ); } /** diff --git a/tests/Fixtures/consumer-a/uninstall.php b/tests/Fixtures/consumer-a/uninstall.php index deb4230..4e494cc 100644 --- a/tests/Fixtures/consumer-a/uninstall.php +++ b/tests/Fixtures/consumer-a/uninstall.php @@ -31,7 +31,7 @@ require_once __DIR__ . '/vendor/autoload.php'; $acme_ca_footprint = Plugin::FOOTPRINT; - $acme_ca_consent = Options::SETTINGS; + $acme_ca_consent = Options::DELETE_DATA; $acme_ca_meta_types = MetaTypes::DEFINITIONS; if ( ! class_exists( Uninstaller::class ) ) { throw new RuntimeException( 'its uninstall engine is missing.' ); @@ -53,10 +53,7 @@ $acme_ca_identity, $acme_ca_footprint, array( - 'consent' => array( - 'option' => $acme_ca_consent, - 'key' => 'delete_data', - ), + 'consent' => array( 'option' => $acme_ca_consent ), 'meta_types' => $acme_ca_meta_types, ) ); diff --git a/tests/Fixtures/consumer-b/composer.lock b/tests/Fixtures/consumer-b/composer.lock index 9bdb98a..71e7ce1 100644 --- a/tests/Fixtures/consumer-b/composer.lock +++ b/tests/Fixtures/consumer-b/composer.lock @@ -2086,12 +2086,12 @@ "source": { "type": "git", "url": "https://github.com/Roave/SecurityAdvisories.git", - "reference": "9fb1049dda127f1732cac541e68f8e490db77825" + "reference": "57a2e6c5a84ea71f685b5b586e6ef32ba7bcf5cc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/9fb1049dda127f1732cac541e68f8e490db77825", - "reference": "9fb1049dda127f1732cac541e68f8e490db77825", + "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/57a2e6c5a84ea71f685b5b586e6ef32ba7bcf5cc", + "reference": "57a2e6c5a84ea71f685b5b586e6ef32ba7bcf5cc", "shasum": "" }, "conflict": { @@ -2227,7 +2227,7 @@ "clickstorm/cs-seo": ">=6,<6.8|>=7,<7.5|>=8,<8.4|>=9,<9.3", "co-stack/fal_sftp": "<0.2.6", "cockpit-hq/cockpit": "<=2.14", - "code16/sharp": "<9.22.5", + "code16/sharp": "<9.23|>=10.0.0.0-beta1,<10.0.0.0-beta4", "codeception/codeception": "<3.1.3|>=4,<4.1.22", "codeigniter/framework": "<3.1.10", "codeigniter4/framework": "<4.7.4", @@ -2571,7 +2571,7 @@ "lara-zeus/artemis": ">=1,<=1.0.6", "lara-zeus/dynamic-dashboard": ">=3,<=3.0.1", "laravel/fortify": "<1.11.1", - "laravel/framework": "<12.69|>=13,<13.30", + "laravel/framework": "<12.71|>=13,<13.40", "laravel/laravel": ">=5.4,<5.4.22", "laravel/passport": ">=13,<13.7.1", "laravel/pulse": "<1.3.1", @@ -2683,7 +2683,7 @@ "nategood/httpful": "<1", "neoan3-apps/template": "<1.1.1", "neorazorx/facturascripts": "<2022.04", - "neos/flow": ">=1,<1.0.4|>=1.1,<1.1.1|>=2,<2.0.1|>=2.3,<2.3.16|>=3,<3.0.12|>=3.1,<3.1.10|>=3.2,<3.2.13|>=3.3,<3.3.13|>=4,<4.0.6", + "neos/flow": "<8.3.22|>=8.4,<8.4.7|>=9,<9.0.10|>=9.1,<9.1.5|>=9.2,<9.2.3", "neos/form": ">=1.2,<4.3.3|>=5,<5.0.9|>=5.1,<5.1.3", "neos/media-browser": "<7.3.19|>=8,<8.0.16|>=8.1,<8.1.11|>=8.2,<8.2.11|>=8.3,<8.3.9", "neos/neos": ">=1.1,<1.1.3|>=1.2,<1.2.13|>=2,<2.0.4|>=2.3,<3.0.20|>=3.1,<3.1.18|>=3.2,<3.2.14|>=3.3,<5.3.10|>=7,<7.0.9|>=7.1,<7.1.7|>=7.2,<7.2.6|>=7.3,<7.3.4|>=8,<8.0.2", @@ -3256,8 +3256,8 @@ "type": "tidelift" } ], - "time": "2026-11-12T14:42:16+00:00", - "published-time": "2026-11-12T14:42:22+00:00" + "time": "2026-11-14T00:11:20+00:00", + "published-time": "2026-11-14T00:11:25+00:00" }, { "name": "sniccowp/php-scoper-wordpress-excludes", diff --git a/tests/Fixtures/consumer-b/src/Settings.php b/tests/Fixtures/consumer-b/src/Settings.php index e7fdeb1..de95d07 100644 --- a/tests/Fixtures/consumer-b/src/Settings.php +++ b/tests/Fixtures/consumer-b/src/Settings.php @@ -10,7 +10,7 @@ \defined( 'ABSPATH' ) || exit; /** - * Adds the plugin's settings page under Settings, whose one tab stores its fields in the plugin's settings option. + * Adds the plugin's settings page under Settings, whose one tab stores each of its fields in an option of its own. * * @since 1.0.0 * @version 1.0.0 @@ -60,20 +60,32 @@ public function get_options_page( string $page_id ): array { 'menu_title' => __( 'Acme Consumer B', 'acme-consumer-b' ), 'tabs' => array( array( - 'slug' => 'general', - 'title' => __( 'General', 'acme-consumer-b' ), - 'option' => Plugin::PREFIX . '_settings', - 'fields' => array( - array( - 'type' => 'text', - 'id' => 'greeting', - 'title' => __( 'Greeting', 'acme-consumer-b' ), - ), - ), + 'slug' => 'general', + 'title' => __( 'General', 'acme-consumer-b' ), ), ), ); } + /** + * {@inheritDoc} + * + * @since 1.0.0 + * @version 1.0.0 + */ + #[\Override] + public function get_tab_fields( string $page_id, string $tab_slug ): array { + return match ( $tab_slug ) { + 'general' => array( + array( + 'type' => 'text', + 'id' => 'acme_cb_greeting', + 'title' => __( 'Greeting', 'acme-consumer-b' ), + ), + ), + default => array(), + }; + } + // endregion } diff --git a/tests/Fixtures/personas/bootstrap/composer.lock b/tests/Fixtures/personas/bootstrap/composer.lock index a983027..807cf8d 100644 --- a/tests/Fixtures/personas/bootstrap/composer.lock +++ b/tests/Fixtures/personas/bootstrap/composer.lock @@ -1685,12 +1685,12 @@ "source": { "type": "git", "url": "https://github.com/Roave/SecurityAdvisories.git", - "reference": "9fb1049dda127f1732cac541e68f8e490db77825" + "reference": "57a2e6c5a84ea71f685b5b586e6ef32ba7bcf5cc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/9fb1049dda127f1732cac541e68f8e490db77825", - "reference": "9fb1049dda127f1732cac541e68f8e490db77825", + "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/57a2e6c5a84ea71f685b5b586e6ef32ba7bcf5cc", + "reference": "57a2e6c5a84ea71f685b5b586e6ef32ba7bcf5cc", "shasum": "" }, "conflict": { @@ -1826,7 +1826,7 @@ "clickstorm/cs-seo": ">=6,<6.8|>=7,<7.5|>=8,<8.4|>=9,<9.3", "co-stack/fal_sftp": "<0.2.6", "cockpit-hq/cockpit": "<=2.14", - "code16/sharp": "<9.22.5", + "code16/sharp": "<9.23|>=10.0.0.0-beta1,<10.0.0.0-beta4", "codeception/codeception": "<3.1.3|>=4,<4.1.22", "codeigniter/framework": "<3.1.10", "codeigniter4/framework": "<4.7.4", @@ -2170,7 +2170,7 @@ "lara-zeus/artemis": ">=1,<=1.0.6", "lara-zeus/dynamic-dashboard": ">=3,<=3.0.1", "laravel/fortify": "<1.11.1", - "laravel/framework": "<12.69|>=13,<13.30", + "laravel/framework": "<12.71|>=13,<13.40", "laravel/laravel": ">=5.4,<5.4.22", "laravel/passport": ">=13,<13.7.1", "laravel/pulse": "<1.3.1", @@ -2282,7 +2282,7 @@ "nategood/httpful": "<1", "neoan3-apps/template": "<1.1.1", "neorazorx/facturascripts": "<2022.04", - "neos/flow": ">=1,<1.0.4|>=1.1,<1.1.1|>=2,<2.0.1|>=2.3,<2.3.16|>=3,<3.0.12|>=3.1,<3.1.10|>=3.2,<3.2.13|>=3.3,<3.3.13|>=4,<4.0.6", + "neos/flow": "<8.3.22|>=8.4,<8.4.7|>=9,<9.0.10|>=9.1,<9.1.5|>=9.2,<9.2.3", "neos/form": ">=1.2,<4.3.3|>=5,<5.0.9|>=5.1,<5.1.3", "neos/media-browser": "<7.3.19|>=8,<8.0.16|>=8.1,<8.1.11|>=8.2,<8.2.11|>=8.3,<8.3.9", "neos/neos": ">=1.1,<1.1.3|>=1.2,<1.2.13|>=2,<2.0.4|>=2.3,<3.0.20|>=3.1,<3.1.18|>=3.2,<3.2.14|>=3.3,<5.3.10|>=7,<7.0.9|>=7.1,<7.1.7|>=7.2,<7.2.6|>=7.3,<7.3.4|>=8,<8.0.2", @@ -2855,8 +2855,8 @@ "type": "tidelift" } ], - "time": "2026-11-12T14:42:16+00:00", - "published-time": "2026-11-12T14:42:22+00:00" + "time": "2026-11-14T00:11:20+00:00", + "published-time": "2026-11-14T00:11:25+00:00" }, { "name": "sniccowp/php-scoper-wordpress-excludes", diff --git a/tests/Fixtures/personas/core/composer.lock b/tests/Fixtures/personas/core/composer.lock index 6273806..d867e76 100644 --- a/tests/Fixtures/personas/core/composer.lock +++ b/tests/Fixtures/personas/core/composer.lock @@ -2051,12 +2051,12 @@ "source": { "type": "git", "url": "https://github.com/Roave/SecurityAdvisories.git", - "reference": "9fb1049dda127f1732cac541e68f8e490db77825" + "reference": "57a2e6c5a84ea71f685b5b586e6ef32ba7bcf5cc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/9fb1049dda127f1732cac541e68f8e490db77825", - "reference": "9fb1049dda127f1732cac541e68f8e490db77825", + "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/57a2e6c5a84ea71f685b5b586e6ef32ba7bcf5cc", + "reference": "57a2e6c5a84ea71f685b5b586e6ef32ba7bcf5cc", "shasum": "" }, "conflict": { @@ -2192,7 +2192,7 @@ "clickstorm/cs-seo": ">=6,<6.8|>=7,<7.5|>=8,<8.4|>=9,<9.3", "co-stack/fal_sftp": "<0.2.6", "cockpit-hq/cockpit": "<=2.14", - "code16/sharp": "<9.22.5", + "code16/sharp": "<9.23|>=10.0.0.0-beta1,<10.0.0.0-beta4", "codeception/codeception": "<3.1.3|>=4,<4.1.22", "codeigniter/framework": "<3.1.10", "codeigniter4/framework": "<4.7.4", @@ -2536,7 +2536,7 @@ "lara-zeus/artemis": ">=1,<=1.0.6", "lara-zeus/dynamic-dashboard": ">=3,<=3.0.1", "laravel/fortify": "<1.11.1", - "laravel/framework": "<12.69|>=13,<13.30", + "laravel/framework": "<12.71|>=13,<13.40", "laravel/laravel": ">=5.4,<5.4.22", "laravel/passport": ">=13,<13.7.1", "laravel/pulse": "<1.3.1", @@ -2648,7 +2648,7 @@ "nategood/httpful": "<1", "neoan3-apps/template": "<1.1.1", "neorazorx/facturascripts": "<2022.04", - "neos/flow": ">=1,<1.0.4|>=1.1,<1.1.1|>=2,<2.0.1|>=2.3,<2.3.16|>=3,<3.0.12|>=3.1,<3.1.10|>=3.2,<3.2.13|>=3.3,<3.3.13|>=4,<4.0.6", + "neos/flow": "<8.3.22|>=8.4,<8.4.7|>=9,<9.0.10|>=9.1,<9.1.5|>=9.2,<9.2.3", "neos/form": ">=1.2,<4.3.3|>=5,<5.0.9|>=5.1,<5.1.3", "neos/media-browser": "<7.3.19|>=8,<8.0.16|>=8.1,<8.1.11|>=8.2,<8.2.11|>=8.3,<8.3.9", "neos/neos": ">=1.1,<1.1.3|>=1.2,<1.2.13|>=2,<2.0.4|>=2.3,<3.0.20|>=3.1,<3.1.18|>=3.2,<3.2.14|>=3.3,<5.3.10|>=7,<7.0.9|>=7.1,<7.1.7|>=7.2,<7.2.6|>=7.3,<7.3.4|>=8,<8.0.2", @@ -3221,8 +3221,8 @@ "type": "tidelift" } ], - "time": "2026-11-12T14:42:16+00:00", - "published-time": "2026-11-12T14:42:22+00:00" + "time": "2026-11-14T00:11:20+00:00", + "published-time": "2026-11-14T00:11:25+00:00" }, { "name": "sniccowp/php-scoper-wordpress-excludes", diff --git a/tests/Fixtures/personas/core/src/Greeting.php b/tests/Fixtures/personas/core/src/Greeting.php index 10a2779..7ec3dd4 100644 --- a/tests/Fixtures/personas/core/src/Greeting.php +++ b/tests/Fixtures/personas/core/src/Greeting.php @@ -51,7 +51,7 @@ public function register_shortcode(): void { * @return string */ public function render(): string { - return esc_html( Options::get_greeting() ); + return esc_html( Options::get_string( Options::GREETING ) ); } // endregion diff --git a/tests/Fixtures/personas/core/src/Migrations.php b/tests/Fixtures/personas/core/src/Migrations.php index ae040b1..c798896 100644 --- a/tests/Fixtures/personas/core/src/Migrations.php +++ b/tests/Fixtures/personas/core/src/Migrations.php @@ -44,18 +44,23 @@ public static function get_steps(): array { // region HELPERS /** - * Stores the plugin's settings with their defaults. + * Stores each setting's default in an autoloaded row of its own and leaves a stored setting as it is. + * + * Each release that adds settings lists this step again, so that existing sites store the new defaults. * * @since 1.0.0 * @version 1.0.0 * - * @throws \RuntimeException Thrown when the settings could not be stored. + * @throws \RuntimeException Thrown when a default could not be stored. * * @return void */ protected static function add_settings(): void { - if ( false === get_option( Options::SETTINGS ) && ! add_option( Options::SETTINGS, Options::DEFAULTS ) ) { - throw new \RuntimeException( 'The settings could not be stored.' ); + wp_prime_option_caches( \array_keys( Options::DEFAULTS ) ); + foreach ( Options::DEFAULTS as $key => $value ) { + if ( false === get_option( $key ) && ! add_option( $key, $value, '', true ) ) { + throw new \RuntimeException( "The option $key could not be stored." ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing, never rendered. + } } } diff --git a/tests/Fixtures/personas/core/src/Options.php b/tests/Fixtures/personas/core/src/Options.php index 48da6c8..61db542 100644 --- a/tests/Fixtures/personas/core/src/Options.php +++ b/tests/Fixtures/personas/core/src/Options.php @@ -5,7 +5,7 @@ \defined( 'ABSPATH' ) || exit; /** - * Names the plugin's stored settings and their defaults, and reads them. + * Names the plugin's stored settings and their defaults, and reads each setting, guarding the stored value. * * @since 1.0.0 * @version 1.0.0 @@ -14,24 +14,37 @@ final class Options { // region FIELDS AND CONSTANTS /** - * The option that holds the plugin's settings: the greeting, and 'yes' under delete_data where the site consents to uninstall removing the plugin's data. + * The option that holds 'yes' where the site consents to uninstall removing the plugin's data. * * @since 1.0.0 * @version 1.0.0 * * @var string */ - public const string SETTINGS = Plugin::PREFIX . '_settings'; + public const string DELETE_DATA = 'acme_pc_delete_data'; /** - * The settings the plugin stores on its first migration. + * The option that holds the greeting. * * @since 1.0.0 * @version 1.0.0 * - * @var array{greeting: string} + * @var string + */ + public const string GREETING = 'acme_pc_greeting'; + + /** + * The default of each option, in the form the option stores it. + * + * @since 1.0.0 + * @version 1.0.0 + * + * @var array */ - public const array DEFAULTS = array( 'greeting' => 'Hello' ); + public const array DEFAULTS = array( + self::DELETE_DATA => 'no', + self::GREETING => 'Hello', + ); // endregion @@ -50,17 +63,19 @@ protected function __construct() {} // region METHODS /** - * Returns the stored greeting, or the default one where none is stored. + * Returns an option as a string, or its default where no string is stored. * * @since 1.0.0 * @version 1.0.0 * + * @param string $key The option's key. + * * @return string */ - public static function get_greeting(): string { - $settings = get_option( self::SETTINGS, self::DEFAULTS ); - - return \is_array( $settings ) && \is_string( $settings['greeting'] ?? null ) ? $settings['greeting'] : self::DEFAULTS['greeting']; + public static function get_string( string $key ): string { + $default = self::DEFAULTS[ $key ] ?? ''; + $stored = get_option( $key, $default ); + return \is_string( $stored ) ? $stored : $default; } // endregion diff --git a/tests/Fixtures/personas/core/src/Plugin.php b/tests/Fixtures/personas/core/src/Plugin.php index ad67182..bc12ef4 100644 --- a/tests/Fixtures/personas/core/src/Plugin.php +++ b/tests/Fixtures/personas/core/src/Plugin.php @@ -70,7 +70,7 @@ final class Plugin { 'option_prefixes' => array( self::PREFIX . '_' ), 'user_meta_site_prefixes' => array( self::PREFIX . '_' ), 'user_meta_global_prefixes' => array( self::PREFIX . '_' ), - 'network_options' => array( self::PREFIX . '_network_notices' ), + 'network_options' => array( 'acme_pc_network_notices' ), 'cron_hooks' => array( 'acme_persona_core/migration/run' ), 'as_groups' => array( self::PREFIX ), ); diff --git a/tests/Fixtures/personas/core/uninstall.php b/tests/Fixtures/personas/core/uninstall.php index a16410f..b441c70 100644 --- a/tests/Fixtures/personas/core/uninstall.php +++ b/tests/Fixtures/personas/core/uninstall.php @@ -30,7 +30,7 @@ require_once __DIR__ . '/vendor/autoload.php'; $acme_pc_footprint = Plugin::FOOTPRINT; - $acme_pc_consent = Options::SETTINGS; + $acme_pc_consent = Options::DELETE_DATA; if ( ! class_exists( Uninstaller::class ) ) { throw new RuntimeException( 'its uninstall engine is missing.' ); } @@ -50,9 +50,6 @@ $acme_pc_identity, $acme_pc_footprint, array( - 'consent' => array( - 'option' => $acme_pc_consent, - 'key' => 'delete_data', - ), + 'consent' => array( 'option' => $acme_pc_consent ), ) ); diff --git a/tests/Fixtures/personas/settings/composer.lock b/tests/Fixtures/personas/settings/composer.lock index 489f4ec..2fff0ef 100644 --- a/tests/Fixtures/personas/settings/composer.lock +++ b/tests/Fixtures/personas/settings/composer.lock @@ -1894,12 +1894,12 @@ "source": { "type": "git", "url": "https://github.com/Roave/SecurityAdvisories.git", - "reference": "9fb1049dda127f1732cac541e68f8e490db77825" + "reference": "57a2e6c5a84ea71f685b5b586e6ef32ba7bcf5cc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/9fb1049dda127f1732cac541e68f8e490db77825", - "reference": "9fb1049dda127f1732cac541e68f8e490db77825", + "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/57a2e6c5a84ea71f685b5b586e6ef32ba7bcf5cc", + "reference": "57a2e6c5a84ea71f685b5b586e6ef32ba7bcf5cc", "shasum": "" }, "conflict": { @@ -2035,7 +2035,7 @@ "clickstorm/cs-seo": ">=6,<6.8|>=7,<7.5|>=8,<8.4|>=9,<9.3", "co-stack/fal_sftp": "<0.2.6", "cockpit-hq/cockpit": "<=2.14", - "code16/sharp": "<9.22.5", + "code16/sharp": "<9.23|>=10.0.0.0-beta1,<10.0.0.0-beta4", "codeception/codeception": "<3.1.3|>=4,<4.1.22", "codeigniter/framework": "<3.1.10", "codeigniter4/framework": "<4.7.4", @@ -2379,7 +2379,7 @@ "lara-zeus/artemis": ">=1,<=1.0.6", "lara-zeus/dynamic-dashboard": ">=3,<=3.0.1", "laravel/fortify": "<1.11.1", - "laravel/framework": "<12.69|>=13,<13.30", + "laravel/framework": "<12.71|>=13,<13.40", "laravel/laravel": ">=5.4,<5.4.22", "laravel/passport": ">=13,<13.7.1", "laravel/pulse": "<1.3.1", @@ -2491,7 +2491,7 @@ "nategood/httpful": "<1", "neoan3-apps/template": "<1.1.1", "neorazorx/facturascripts": "<2022.04", - "neos/flow": ">=1,<1.0.4|>=1.1,<1.1.1|>=2,<2.0.1|>=2.3,<2.3.16|>=3,<3.0.12|>=3.1,<3.1.10|>=3.2,<3.2.13|>=3.3,<3.3.13|>=4,<4.0.6", + "neos/flow": "<8.3.22|>=8.4,<8.4.7|>=9,<9.0.10|>=9.1,<9.1.5|>=9.2,<9.2.3", "neos/form": ">=1.2,<4.3.3|>=5,<5.0.9|>=5.1,<5.1.3", "neos/media-browser": "<7.3.19|>=8,<8.0.16|>=8.1,<8.1.11|>=8.2,<8.2.11|>=8.3,<8.3.9", "neos/neos": ">=1.1,<1.1.3|>=1.2,<1.2.13|>=2,<2.0.4|>=2.3,<3.0.20|>=3.1,<3.1.18|>=3.2,<3.2.14|>=3.3,<5.3.10|>=7,<7.0.9|>=7.1,<7.1.7|>=7.2,<7.2.6|>=7.3,<7.3.4|>=8,<8.0.2", @@ -3064,8 +3064,8 @@ "type": "tidelift" } ], - "time": "2026-11-12T14:42:16+00:00", - "published-time": "2026-11-12T14:42:22+00:00" + "time": "2026-11-14T00:11:20+00:00", + "published-time": "2026-11-14T00:11:25+00:00" }, { "name": "sniccowp/php-scoper-wordpress-excludes", diff --git a/tests/Fixtures/personas/settings/src/Details.php b/tests/Fixtures/personas/settings/src/Details.php index 32112e9..29d7d1e 100644 --- a/tests/Fixtures/personas/settings/src/Details.php +++ b/tests/Fixtures/personas/settings/src/Details.php @@ -29,7 +29,7 @@ public function get_object_form( string $form_id, int $object_id ): array { 'fields' => array( array( 'type' => 'text', - 'id' => '_' . Settings::PREFIX . '_subtitle', + 'id' => '_acme_ps_subtitle', 'title' => __( 'Subtitle', 'acme-persona-settings' ), ), ), diff --git a/tests/Fixtures/personas/settings/src/Settings.php b/tests/Fixtures/personas/settings/src/Settings.php index 8d605c2..db8df7c 100644 --- a/tests/Fixtures/personas/settings/src/Settings.php +++ b/tests/Fixtures/personas/settings/src/Settings.php @@ -7,7 +7,7 @@ \defined( 'ABSPATH' ) || exit; /** - * Defines the plugin's settings page under Settings, whose one tab stores a limit of at least 1 in the plugin's settings option. + * Defines the plugin's settings page under Settings, whose one tab stores a whole-number limit of at least 1 in an option of its own. * * @since 1.0.0 * @version 1.0.0 @@ -26,7 +26,7 @@ final class Settings implements OptionsPageDefinitionInterface { public const string SLUG = 'acme-persona-settings'; /** - * The prefix of the plugin's admin page and option. + * The prefix of the plugin's admin page and of the names it stores its data under. * * @since 1.0.0 * @version 1.0.0 @@ -54,45 +54,36 @@ public function get_options_page( string $page_id ): array { 'menu_title' => __( 'Acme Persona Settings', 'acme-persona-settings' ), 'tabs' => array( array( - 'slug' => 'general', - 'title' => __( 'General', 'acme-persona-settings' ), - 'option' => self::PREFIX . '_settings', - 'fields' => array( - array( - 'type' => 'number', - 'id' => 'limit', - 'title' => __( 'Limit', 'acme-persona-settings' ), - 'default' => '10', - 'custom_attributes' => array( - 'min' => '1', - 'step' => '1', - ), - 'validate' => array( $this, 'validate_limit' ), - ), - ), + 'slug' => 'general', + 'title' => __( 'General', 'acme-persona-settings' ), ), ), ); } - // endregion - - // region METHODS - /** - * Returns the submitted limit as an integer, or an error when it is not a whole number of at least 1. + * {@inheritDoc} * * @since 1.0.0 * @version 1.0.0 - * - * @param mixed $raw The submitted value. - * - * @return int|\WP_Error */ - public function validate_limit( mixed $raw ): int|\WP_Error { - $limit = \filter_var( $raw, \FILTER_VALIDATE_INT, array( 'options' => array( 'min_range' => 1 ) ) ); - - return false === $limit ? new \WP_Error( 'acme_ps_limit', __( 'Enter a whole number of at least 1.', 'acme-persona-settings' ) ) : $limit; + #[\Override] + public function get_tab_fields( string $page_id, string $tab_slug ): array { + return match ( $tab_slug ) { + 'general' => array( + array( + 'type' => 'number', + 'id' => 'acme_ps_limit', + 'title' => __( 'Limit', 'acme-persona-settings' ), + 'default' => '10', + 'custom_attributes' => array( + 'min' => '1', + 'step' => '1', + ), + ), + ), + default => array(), + }; } // endregion diff --git a/tests/Fixtures/personas/shared/composer.lock b/tests/Fixtures/personas/shared/composer.lock index 766148d..6b236bc 100644 --- a/tests/Fixtures/personas/shared/composer.lock +++ b/tests/Fixtures/personas/shared/composer.lock @@ -1689,12 +1689,12 @@ "source": { "type": "git", "url": "https://github.com/Roave/SecurityAdvisories.git", - "reference": "9fb1049dda127f1732cac541e68f8e490db77825" + "reference": "57a2e6c5a84ea71f685b5b586e6ef32ba7bcf5cc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/9fb1049dda127f1732cac541e68f8e490db77825", - "reference": "9fb1049dda127f1732cac541e68f8e490db77825", + "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/57a2e6c5a84ea71f685b5b586e6ef32ba7bcf5cc", + "reference": "57a2e6c5a84ea71f685b5b586e6ef32ba7bcf5cc", "shasum": "" }, "conflict": { @@ -1830,7 +1830,7 @@ "clickstorm/cs-seo": ">=6,<6.8|>=7,<7.5|>=8,<8.4|>=9,<9.3", "co-stack/fal_sftp": "<0.2.6", "cockpit-hq/cockpit": "<=2.14", - "code16/sharp": "<9.22.5", + "code16/sharp": "<9.23|>=10.0.0.0-beta1,<10.0.0.0-beta4", "codeception/codeception": "<3.1.3|>=4,<4.1.22", "codeigniter/framework": "<3.1.10", "codeigniter4/framework": "<4.7.4", @@ -2174,7 +2174,7 @@ "lara-zeus/artemis": ">=1,<=1.0.6", "lara-zeus/dynamic-dashboard": ">=3,<=3.0.1", "laravel/fortify": "<1.11.1", - "laravel/framework": "<12.69|>=13,<13.30", + "laravel/framework": "<12.71|>=13,<13.40", "laravel/laravel": ">=5.4,<5.4.22", "laravel/passport": ">=13,<13.7.1", "laravel/pulse": "<1.3.1", @@ -2286,7 +2286,7 @@ "nategood/httpful": "<1", "neoan3-apps/template": "<1.1.1", "neorazorx/facturascripts": "<2022.04", - "neos/flow": ">=1,<1.0.4|>=1.1,<1.1.1|>=2,<2.0.1|>=2.3,<2.3.16|>=3,<3.0.12|>=3.1,<3.1.10|>=3.2,<3.2.13|>=3.3,<3.3.13|>=4,<4.0.6", + "neos/flow": "<8.3.22|>=8.4,<8.4.7|>=9,<9.0.10|>=9.1,<9.1.5|>=9.2,<9.2.3", "neos/form": ">=1.2,<4.3.3|>=5,<5.0.9|>=5.1,<5.1.3", "neos/media-browser": "<7.3.19|>=8,<8.0.16|>=8.1,<8.1.11|>=8.2,<8.2.11|>=8.3,<8.3.9", "neos/neos": ">=1.1,<1.1.3|>=1.2,<1.2.13|>=2,<2.0.4|>=2.3,<3.0.20|>=3.1,<3.1.18|>=3.2,<3.2.14|>=3.3,<5.3.10|>=7,<7.0.9|>=7.1,<7.1.7|>=7.2,<7.2.6|>=7.3,<7.3.4|>=8,<8.0.2", @@ -2859,8 +2859,8 @@ "type": "tidelift" } ], - "time": "2026-11-12T14:42:16+00:00", - "published-time": "2026-11-12T14:42:22+00:00" + "time": "2026-11-14T00:11:20+00:00", + "published-time": "2026-11-14T00:11:25+00:00" }, { "name": "sniccowp/php-scoper-wordpress-excludes", diff --git a/tests/Fixtures/personas/woocommerce/composer.lock b/tests/Fixtures/personas/woocommerce/composer.lock index b8818bf..ba1f287 100644 --- a/tests/Fixtures/personas/woocommerce/composer.lock +++ b/tests/Fixtures/personas/woocommerce/composer.lock @@ -1981,12 +1981,12 @@ "source": { "type": "git", "url": "https://github.com/Roave/SecurityAdvisories.git", - "reference": "9fb1049dda127f1732cac541e68f8e490db77825" + "reference": "57a2e6c5a84ea71f685b5b586e6ef32ba7bcf5cc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/9fb1049dda127f1732cac541e68f8e490db77825", - "reference": "9fb1049dda127f1732cac541e68f8e490db77825", + "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/57a2e6c5a84ea71f685b5b586e6ef32ba7bcf5cc", + "reference": "57a2e6c5a84ea71f685b5b586e6ef32ba7bcf5cc", "shasum": "" }, "conflict": { @@ -2122,7 +2122,7 @@ "clickstorm/cs-seo": ">=6,<6.8|>=7,<7.5|>=8,<8.4|>=9,<9.3", "co-stack/fal_sftp": "<0.2.6", "cockpit-hq/cockpit": "<=2.14", - "code16/sharp": "<9.22.5", + "code16/sharp": "<9.23|>=10.0.0.0-beta1,<10.0.0.0-beta4", "codeception/codeception": "<3.1.3|>=4,<4.1.22", "codeigniter/framework": "<3.1.10", "codeigniter4/framework": "<4.7.4", @@ -2466,7 +2466,7 @@ "lara-zeus/artemis": ">=1,<=1.0.6", "lara-zeus/dynamic-dashboard": ">=3,<=3.0.1", "laravel/fortify": "<1.11.1", - "laravel/framework": "<12.69|>=13,<13.30", + "laravel/framework": "<12.71|>=13,<13.40", "laravel/laravel": ">=5.4,<5.4.22", "laravel/passport": ">=13,<13.7.1", "laravel/pulse": "<1.3.1", @@ -2578,7 +2578,7 @@ "nategood/httpful": "<1", "neoan3-apps/template": "<1.1.1", "neorazorx/facturascripts": "<2022.04", - "neos/flow": ">=1,<1.0.4|>=1.1,<1.1.1|>=2,<2.0.1|>=2.3,<2.3.16|>=3,<3.0.12|>=3.1,<3.1.10|>=3.2,<3.2.13|>=3.3,<3.3.13|>=4,<4.0.6", + "neos/flow": "<8.3.22|>=8.4,<8.4.7|>=9,<9.0.10|>=9.1,<9.1.5|>=9.2,<9.2.3", "neos/form": ">=1.2,<4.3.3|>=5,<5.0.9|>=5.1,<5.1.3", "neos/media-browser": "<7.3.19|>=8,<8.0.16|>=8.1,<8.1.11|>=8.2,<8.2.11|>=8.3,<8.3.9", "neos/neos": ">=1.1,<1.1.3|>=1.2,<1.2.13|>=2,<2.0.4|>=2.3,<3.0.20|>=3.1,<3.1.18|>=3.2,<3.2.14|>=3.3,<5.3.10|>=7,<7.0.9|>=7.1,<7.1.7|>=7.2,<7.2.6|>=7.3,<7.3.4|>=8,<8.0.2", @@ -3151,8 +3151,8 @@ "type": "tidelift" } ], - "time": "2026-11-12T14:42:16+00:00", - "published-time": "2026-11-12T14:42:22+00:00" + "time": "2026-11-14T00:11:20+00:00", + "published-time": "2026-11-14T00:11:25+00:00" }, { "name": "sniccowp/php-scoper-wordpress-excludes", diff --git a/tests/Fixtures/personas/woocommerce/src/Options.php b/tests/Fixtures/personas/woocommerce/src/Options.php index 6e6adb8..3898535 100644 --- a/tests/Fixtures/personas/woocommerce/src/Options.php +++ b/tests/Fixtures/personas/woocommerce/src/Options.php @@ -21,7 +21,7 @@ final class Options { * * @var string */ - public const string DELETE_DATA = Packing::PREFIX . '_delete_data'; + public const string DELETE_DATA = 'acme_pw_delete_data'; // endregion diff --git a/tests/Fixtures/personas/woocommerce/src/Packing.php b/tests/Fixtures/personas/woocommerce/src/Packing.php index 735d848..713809c 100644 --- a/tests/Fixtures/personas/woocommerce/src/Packing.php +++ b/tests/Fixtures/personas/woocommerce/src/Packing.php @@ -44,7 +44,7 @@ final class Packing implements ProductDataTabDefinitionInterface, SectionFieldsD * * @var string */ - public const string META_BOX = '_' . self::PREFIX . '_box'; + public const string META_BOX = '_acme_pw_box'; /** * The plugin's stored data by kind: every option under the plugin's prefix, and each product's box. @@ -74,7 +74,7 @@ public function get_section_fields( string $tab_id, string $section_id ): array return array( array( 'type' => 'text', - 'id' => self::PREFIX . '_default_box', + 'id' => 'acme_pw_default_box', 'title' => __( 'Default box', 'acme-persona-woocommerce' ), ), array( diff --git a/tests/Integration/ConsumerA/ConsumerABuildTest.php b/tests/Integration/ConsumerA/ConsumerABuildTest.php index 7147225..6e7d3fc 100644 --- a/tests/Integration/ConsumerA/ConsumerABuildTest.php +++ b/tests/Integration/ConsumerA/ConsumerABuildTest.php @@ -46,6 +46,19 @@ public function test_a_missing_build_file_shows_the_build_notice_to_plugin_manag self::assertStringNotContainsString( self::BUILD_NOTICE, $editor['body'] ); } + public function test_an_include_that_throws_shows_the_build_notice_and_leaves_the_plugin_unbooted(): void { + $plugin = $this->install_consumer( 'consumer-a', 'acme-consumer-a' ); + \file_put_contents( WP_PLUGIN_DIR . '/acme-consumer-a/includes/throws.php', "consumer_request( 'GET', 'index.php', $this->make_user( 'acme_ca_admin', 'administrator' ), array( 'activate' => $plugin ) ); + + self::assertNull( $request['died'] ); + self::assertStringContainsString( self::BUILD_NOTICE, $request['body'] ); + self::assertSame( 0, $request['records']['consumer_a']['boot_succeeded'] ); + self::assertNotSame( 'booted', $request['records']['consumer_a']['status'] ); + self::assertFalse( $request['records']['export_hooked'] ); + } + public function test_a_site_below_the_php_floor_keeps_the_safeguard_and_gets_core_s_message_and_the_updater_while_nothing_else_loads(): void { $plugin = $this->install_consumer( 'consumer-a', 'acme-consumer-a', array(), array( 'Requires PHP' => '99' ) ); $request = $this->consumer_request( diff --git a/tests/Integration/ConsumerA/ConsumerASettingsTest.php b/tests/Integration/ConsumerA/ConsumerASettingsTest.php new file mode 100644 index 0000000..9f17566 --- /dev/null +++ b/tests/Integration/ConsumerA/ConsumerASettingsTest.php @@ -0,0 +1,76 @@ + array( 'yes', true ), + 'never-saved' => array( null, false ), + ); + } + + public function test_the_first_request_stores_each_default_in_an_autoloaded_row_of_its_own(): void { + global $wpdb; + + $plugin = $this->install_consumer( 'consumer-a', 'acme-consumer-a' ); + $this->consumer_request( 'GET', 'index.php', $this->make_user( 'acme_ca_admin', 'administrator' ), array( 'activate' => $plugin ) ); + wp_cache_flush(); + + $autoload = $wpdb->get_results( $wpdb->prepare( 'SELECT option_name, autoload FROM %i WHERE option_name IN ( %s, %s ) ORDER BY option_name', $wpdb->options, 'acme_ca_delete_data', 'acme_ca_reports' ), \ARRAY_A ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Reads the rows' autoload flags. + self::assertSame( 'no', get_option( 'acme_ca_delete_data' ) ); + self::assertSame( 'no', get_option( 'acme_ca_reports' ) ); + self::assertSame( array( 'acme_ca_delete_data', 'acme_ca_reports' ), \array_column( $autoload, 'option_name' ) ); + self::assertSame( array(), \array_diff( \array_column( $autoload, 'autoload' ), wp_autoload_values_to_autoload() ) ); + self::assertFalse( get_option( 'acme_ca_settings' ) ); + } + + public function test_seeding_leaves_a_stored_setting_as_it_is(): void { + update_option( 'acme_ca_delete_data', 'yes' ); + $plugin = $this->install_consumer( 'consumer-a', 'acme-consumer-a' ); + $this->consumer_request( 'GET', 'index.php', $this->make_user( 'acme_ca_admin', 'administrator' ), array( 'activate' => $plugin ) ); + wp_cache_flush(); + + self::assertSame( 'yes', get_option( 'acme_ca_delete_data' ) ); + self::assertSame( 'no', get_option( 'acme_ca_reports' ) ); + } + + #[DataProvider( 'stored_reports_settings' )] + public function test_the_reports_accessor_reads_what_the_reports_gate_reads( ?string $stored, bool $enabled ): void { + if ( ! \is_null( $stored ) ) { + update_option( 'acme_ca_reports', $stored ); + } + $plugin = $this->install_consumer( 'consumer-a', 'acme-consumer-a' ); + $request = $this->consumer_request( 'GET', 'index.php', $this->make_user( 'acme_ca_admin', 'administrator' ), array( 'activate' => $plugin ) ); + + self::assertSame( $enabled, $request['records']['reports_enabled'] ); + self::assertSame( $enabled, $request['records']['reports_page_class'] ); + } + + #[RunInSeparateProcess] + public function test_each_seeding_step_records_the_key_set_it_stores(): void { + require_once self::FIXTURES . '/consumer-a/vendor/autoload.php'; + + $seeding = \array_filter( \call_user_func( array( 'Acme\\ConsumerA\\Migrations', 'get_steps' ) ), static fn ( mixed $step ): bool => $step instanceof \Closure && 'add_settings' === new \ReflectionFunction( $step )->getName() ); + $keys = \array_keys( \constant( 'Acme\\ConsumerA\\Options::DEFAULTS' ) ); + \sort( $keys ); + $fingerprint = \hash( 'xxh3', \implode( "\n", $keys ) ); + + self::assertCount( \count( self::SEEDED_KEY_SETS ), $seeding, 'Each seeding step needs its fingerprint in SEEDED_KEY_SETS.' ); + self::assertSame( \array_last( self::SEEDED_KEY_SETS ), $fingerprint, "Options::DEFAULTS' keys changed: append self::add_settings( ... ) to Migrations::get_steps() and '$fingerprint' to SEEDED_KEY_SETS." ); + } +} diff --git a/tests/Integration/ConsumerA/ConsumerAUninstallTest.php b/tests/Integration/ConsumerA/ConsumerAUninstallTest.php index 891b95d..a2ddeb2 100644 --- a/tests/Integration/ConsumerA/ConsumerAUninstallTest.php +++ b/tests/Integration/ConsumerA/ConsumerAUninstallTest.php @@ -43,7 +43,7 @@ public function test_a_consenting_web_uninstall_removes_the_footprint_and_the_fi self::assertTrue( $request['records']['deleted'] ); self::assertDirectoryDoesNotExist( WP_PLUGIN_DIR . '/' . self::DIR ); - self::assertFalse( get_option( 'acme_ca_settings' ) ); + self::assertFalse( get_option( 'acme_ca_delete_data' ) ); self::assertFalse( get_option( 'acme_ca_revision' ) ); self::assertSame( '', get_post_meta( $this->product_id, '_acme_ca_packing_note', true ) ); self::assertFalse( get_option( 'acme_ca_notices' ) ); @@ -64,7 +64,7 @@ public function test_a_web_uninstall_whose_engine_fails_shows_its_message_and_ke self::assertStringStartsWith( 'Uninstall could not remove everything: option acme_ca_revision: ', $request['died']['message'] ?? '' ); self::assertStringEndsWith( ' exist.', $request['died']['message'] ?? '' ); self::assertFileExists( WP_PLUGIN_DIR . '/' . self::DIR . '/acme-consumer-a.php' ); - self::assertSame( array( 'delete_data' => 'yes' ), get_option( 'acme_ca_settings' ) ); + self::assertSame( 'yes', get_option( 'acme_ca_delete_data' ) ); self::assertSame( '1', get_option( 'acme_ca_revision' ) ); } @@ -76,7 +76,7 @@ public function test_a_web_uninstall_that_cannot_run_the_engine_keeps_the_data_l self::assertTrue( $request['records']['deleted'] ); self::assertDirectoryDoesNotExist( WP_PLUGIN_DIR . '/' . self::DIR ); - self::assertSame( array( 'delete_data' => 'yes' ), get_option( 'acme_ca_settings' ) ); + self::assertSame( 'yes', get_option( 'acme_ca_delete_data' ) ); self::assertSame( 'kept', get_post_meta( $this->product_id, '_acme_ca_packing_note', true ) ); self::assertSame( 1, \substr_count( (string) \file_get_contents( $this->log ), 'Acme Consumer A kept its data on uninstall' ) ); } @@ -91,7 +91,7 @@ public function test_a_wp_cli_uninstall_that_cannot_run_the_engine_fails_and_kee self::assertNotSame( 0, $exit_code, $output . $errors ); self::assertFileExists( WP_PLUGIN_DIR . '/' . self::DIR . '/acme-consumer-a.php' ); - self::assertSame( array( 'delete_data' => 'yes' ), get_option( 'acme_ca_settings' ) ); + self::assertSame( 'yes', get_option( 'acme_ca_delete_data' ) ); self::assertSame( 'kept', get_post_meta( $this->product_id, '_acme_ca_packing_note', true ) ); } @@ -103,7 +103,7 @@ protected function delete_request_log(): void { } protected function seed_footprint(): void { - update_option( 'acme_ca_settings', array( 'delete_data' => 'yes' ) ); + update_option( 'acme_ca_delete_data', 'yes' ); update_option( 'acme_ca_revision', 1 ); update_option( 'acme_ca_notices', array( 'seeded' ) ); update_network_option( null, 'acme_ca_network_notices', array( 'seeded' ) ); diff --git a/tests/Integration/ConsumerB/ConsumerBBootTest.php b/tests/Integration/ConsumerB/ConsumerBBootTest.php index 2729189..aea3249 100644 --- a/tests/Integration/ConsumerB/ConsumerBBootTest.php +++ b/tests/Integration/ConsumerB/ConsumerBBootTest.php @@ -21,6 +21,6 @@ public function test_the_plugin_boots_without_woocommerce_and_its_settings_tab_r self::assertNull( $request['died'] ); self::assertFalse( $request['records']['woocommerce_present'] ); self::assertSame( 'booted', $request['records']['consumer_b']['status'] ); - self::assertStringContainsString( 'name="acme_cb_settings[greeting]"', $request['body'] ); + self::assertStringContainsString( 'name="acme_cb_greeting"', $request['body'] ); } } diff --git a/tests/Integration/Core/Uninstall/UninstallerTest.php b/tests/Integration/Core/Uninstall/UninstallerTest.php index 9d65733..1817efc 100644 --- a/tests/Integration/Core/Uninstall/UninstallerTest.php +++ b/tests/Integration/Core/Uninstall/UninstallerTest.php @@ -26,9 +26,7 @@ public static function retaining_consents(): array { 'option-one' => array( self::stored_consent( '1' ) ), 'option-uppercase-yes' => array( self::stored_consent( 'YES' ) ), 'option-true' => array( self::stored_consent( true ) ), - 'grouped-row-without-key' => array( self::stored_consent( array( 'other' => 'yes' ), 'remove_data' ) ), - 'grouped-key-no' => array( self::stored_consent( array( 'remove_data' => 'no' ), 'remove_data' ) ), - 'flat-yes-read-as-grouped' => array( self::stored_consent( 'yes', 'remove_data' ) ), + 'option-array-holding-yes' => array( self::stored_consent( array( 'remove_data' => 'yes' ) ) ), 'constant-yes-string' => array( self::defined_consent( 'yes' ) ), 'constant-undefined' => array( static fn ( string $prefix ): array => array( 'consent' => array( 'constant' => \strtoupper( "{$prefix}_undefined" ) ) ) ), ); @@ -36,8 +34,7 @@ public static function retaining_consents(): array { public static function consents(): array { return array( - 'flat-yes' => array( self::stored_consent( 'yes' ) ), - 'grouped-yes' => array( self::stored_consent( array( 'remove_data' => 'yes' ), 'remove_data' ) ), + 'option-yes' => array( self::stored_consent( 'yes' ) ), 'constant-true' => array( self::defined_consent( true ) ), 'constant-over-stored-no' => array( static function ( string $prefix ): array { @@ -498,11 +495,11 @@ public function test_an_action_scheduler_cancel_that_throws_is_logged_without_ho self::assertFalse( $this->option_exists( "{$this->prefix}_remove_data" ) ); } - protected static function stored_consent( mixed $value, string $key = '' ): \Closure { - return static function ( string $prefix ) use ( $value, $key ): array { + protected static function stored_consent( mixed $value ): \Closure { + return static function ( string $prefix ) use ( $value ): array { update_option( "{$prefix}_remove_data", $value ); - return array( 'consent' => array( 'option' => "{$prefix}_remove_data" ) + ( '' === $key ? array() : array( 'key' => $key ) ) ); + return array( 'consent' => array( 'option' => "{$prefix}_remove_data" ) ); }; } diff --git a/tests/Integration/Personas/CorePersonaTest.php b/tests/Integration/Personas/CorePersonaTest.php index 612805f..fa6b567 100644 --- a/tests/Integration/Personas/CorePersonaTest.php +++ b/tests/Integration/Personas/CorePersonaTest.php @@ -24,23 +24,21 @@ public function test_the_plugin_boots_its_components_and_runs_its_migration_with self::assertSame( 1, $request['records']['persona_core']['boot_succeeded'] ); self::assertSame( 'Acme\\PersonaCore\\Scoped\\DeepWebSolutions\\Framework\\Core\\Migration\\MigrationRunner', $request['records']['persona_core']['migrations'] ); self::assertTrue( $request['records']['greeting_shortcode'] ); - self::assertSame( array( 'greeting' => 'Hello' ), get_option( 'acme_pc_settings' ) ); + self::assertSame( 'Hello', get_option( 'acme_pc_greeting' ) ); + self::assertSame( 'no', get_option( 'acme_pc_delete_data' ) ); + self::assertFalse( get_option( 'acme_pc_settings' ) ); } public function test_a_consenting_uninstall_removes_the_plugin_s_options(): void { - update_option( - 'acme_pc_settings', - array( - 'greeting' => 'Hello', - 'delete_data' => 'yes', - ) - ); + update_option( 'acme_pc_greeting', 'Hello' ); + update_option( 'acme_pc_delete_data', 'yes' ); update_option( 'acme_pc_revision', 1 ); $plugin = $this->install_consumer( 'personas/core', 'acme-persona-core' ); $request = $this->consumer_request( 'GET', 'index.php', $this->make_user( 'acme_pc_admin', 'administrator' ), array( 'uninstall' => $plugin ) ); self::assertTrue( $request['records']['deleted'] ); - self::assertFalse( get_option( 'acme_pc_settings' ) ); + self::assertFalse( get_option( 'acme_pc_greeting' ) ); + self::assertFalse( get_option( 'acme_pc_delete_data' ) ); self::assertFalse( get_option( 'acme_pc_revision' ) ); } } diff --git a/tests/Integration/Personas/SettingsPersonaTest.php b/tests/Integration/Personas/SettingsPersonaTest.php index 3124df5..e834396 100644 --- a/tests/Integration/Personas/SettingsPersonaTest.php +++ b/tests/Integration/Personas/SettingsPersonaTest.php @@ -25,18 +25,18 @@ public function test_a_valid_limit_saved_through_options_php_is_stored(): void { $saved = $this->save_limit( '5' ); self::assertNull( $saved['died'] ); - self::assertSame( array( 'limit' => 5 ), get_option( 'acme_ps_settings' ) ); + self::assertSame( '5', get_option( 'acme_ps_limit' ) ); } public function test_a_rejected_limit_keeps_the_stored_one_and_its_message_shows_on_the_page(): void { - update_option( 'acme_ps_settings', array( 'limit' => 5 ) ); + update_option( 'acme_ps_limit', '5' ); $saved = $this->save_limit( '0' ); - self::assertSame( array( 'limit' => 5 ), get_option( 'acme_ps_settings' ) ); + self::assertSame( '5', get_option( 'acme_ps_limit' ) ); self::assertIsString( $saved['location'] ); $page = $this->consumer_request( 'GET', \explode( '/wp-admin/', $saved['location'], 2 )[1], $saved['user'], $saved['spec'] ); - self::assertStringContainsString( 'Limit was not saved and keeps its previous value. The other changes were saved. Enter a whole number of at least 1.', $page['body'] ); + self::assertStringContainsString( 'Limit was not saved and keeps its previous value. The other changes were saved. Enter a number of at least 1.', $page['body'] ); } public function test_the_post_form_saves_its_field_on_the_post_through_the_block_editor_s_meta_box_save(): void { @@ -60,7 +60,7 @@ public function test_the_post_form_saves_its_field_on_the_post_through_the_block protected function save_limit( string $limit ): array { $user = $this->make_user( 'acme_ps_admin', 'administrator' ); $spec = array( 'activate' => $this->install_consumer( 'personas/settings', 'acme-persona-settings' ) ); - $data = self::serialize_form( $this->consumer_request( 'GET', 'options-general.php?page=acme_ps', $user, $spec )['body'], 'form[action="options.php"]', array( 'acme_ps_settings[limit]' => $limit ) ); + $data = self::serialize_form( $this->consumer_request( 'GET', 'options-general.php?page=acme_ps', $user, $spec )['body'], 'form[action="options.php"]', array( 'acme_ps_limit' => $limit ) ); return $this->consumer_request( 'POST', 'options.php', $user, $spec, $data ) + array( 'user' => $user, diff --git a/tests/Integration/Settings/AdminPage/OptionsPageTest.php b/tests/Integration/Settings/AdminPage/OptionsPageTest.php index 25bade3..7ac23a6 100644 --- a/tests/Integration/Settings/AdminPage/OptionsPageTest.php +++ b/tests/Integration/Settings/AdminPage/OptionsPageTest.php @@ -9,6 +9,7 @@ use DeepWebSolutions\Framework\Tests\Integration\Support\IsolatesStorageTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\ProvidesHelperIdsTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\RecordedDefinition; +use DeepWebSolutions\Framework\Tests\Integration\Support\RecordsDoingItWrongTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\RunsAdminRequestsTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\SerializesFormsTrait; use PHPUnit\Framework\Attributes\CoversClass; @@ -21,6 +22,7 @@ final class OptionsPageTest extends TestCase { use IsolatesHooksTrait; use IsolatesStorageTrait; use ProvidesHelperIdsTrait; + use RecordsDoingItWrongTrait; use RunsAdminRequestsTrait; use SerializesFormsTrait; @@ -41,19 +43,28 @@ final class OptionsPageTest extends TestCase { protected int $admin; protected int $editor; protected string $id; - protected string $option; + protected string $group; + protected mixed $pagenow; #[\Override] protected function setUp(): void { - $this->admin = $this->make_user( 'acme_options_admin', 'administrator' ); - $this->editor = $this->make_user( 'acme_options_editor', 'editor' ); - $this->id = "{$this->prefix}_settings"; - $this->option = "{$this->prefix}_general"; + $this->admin = $this->make_user( 'acme_options_admin', 'administrator' ); + $this->editor = $this->make_user( 'acme_options_editor', 'editor' ); + $this->id = "{$this->prefix}_settings"; + $this->group = "{$this->id}_general"; + $this->pagenow = $GLOBALS['pagenow'] ?? null; } #[\Override] protected function tearDown(): void { delete_transient( 'settings_errors' ); + $GLOBALS['pagenow'] = $this->pagenow; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restores what an in-process row replaced. + unset( $_REQUEST['option_page'] ); + foreach ( \array_keys( get_registered_settings() ) as $name ) { + if ( \str_starts_with( $name, $this->prefix ) ) { + unregister_setting( $this->group, $name ); + } + } } public static function parents(): array { @@ -118,79 +129,203 @@ public function test_the_definition_receives_the_pages_id_once(): void { self::assertSame( array( array( $this->id ) ), $definition->calls ); } + public function test_only_the_tab_a_request_shows_or_saves_asks_for_its_fields(): void { + $spec = $this->spec( + array( $this->field( 'text', 'name' ) ), + array( 'tabs' => array( self::tab( 'general' ), self::tab( 'second' ) ) ), + array( 'fields' => array( 'second' => array( $this->field( 'text', 'other' ) ) ) ) + ); + + $elsewhere = $this->admin_request( 'GET', 'index.php', $this->admin, self::FIXTURE, $spec ); + $shown = $this->open( $spec, $this->admin, 'second' ); + $saved = $this->save( $spec, $this->admin, self::form_data( $shown['body'], array( "{$this->prefix}_other" => 'new' ) ) ); + + self::assertArrayNotHasKey( 'asked', $elsewhere ); + self::assertSame( array( 'second' ), $shown['asked'] ); + self::assertSame( array( 'second' ), $saved['asked'] ); + self::assertSame( 'new', get_option( "{$this->prefix}_other" ) ); + } + + public function test_a_field_id_outside_the_plugins_prefix_or_the_helper_id_pattern_is_dropped_with_a_notice(): void { + $this->record_doing_it_wrong(); + $fields = array( + array( + 'type' => 'text', + 'id' => "{$this->prefix}_name", + ), + array( + 'type' => 'text', + 'id' => "{$this->prefix}x_name", + ), + array( + 'type' => 'text', + 'id' => "{$this->prefix}_Name", + ), + ); + $page = new OptionsPage( + $this->identity, + $this->id, + new RecordedDefinition( fn ( string $page_id, ?string $tab_slug = null ): array => \is_null( $tab_slug ) ? $this->spec( array() )['page'] : $fields ) + ); + $GLOBALS['pagenow'] = 'options.php'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Runs the registration as options.php does. + $_REQUEST['option_page'] = $this->group; + + $page->register_settings(); + + self::assertSame( array( "{$this->prefix}_name" ), \array_values( \array_filter( \array_keys( get_registered_settings() ), fn ( string $name ): bool => \str_starts_with( $name, $this->prefix ) ) ) ); + self::assertSame( array( OptionsPage::class . '::get_fields', OptionsPage::class . '::get_fields' ), $this->doing_it_wrong ); + } + + public function test_a_dropped_field_neither_renders_nor_stores_what_is_posted_for_it(): void { + $spec = $this->spec( array( $this->field( 'text', 'name' ) ), array(), array( 'contributed' => array( 'fields' => array( 'general' => array( $this->field( 'text', 'blogname', array( 'id' => "{$this->prefix}x_blogname" ) ) ) ) ) ) ); + $form = $this->open( $spec, $this->admin ); + $data = self::form_data( $form['body'], array( "{$this->prefix}_name" => 'new' ) ) + array( "{$this->prefix}x_blogname" => 'injected' ); + + $this->save( $spec, $this->admin, $data ); + + self::assertSame( array( 'Name' ), self::row_titles( $form['body'] ) ); + self::assertCount( 1, $form['doing_it_wrong'] ?? array() ); + self::assertStringContainsString( "{$this->prefix}x_blogname", $form['doing_it_wrong'][0][1] ?? '' ); + self::assertSame( 'new', get_option( "{$this->prefix}_name" ) ); + self::assertFalse( get_option( "{$this->prefix}x_blogname" ) ); + } + + public function test_each_field_registers_in_the_tabs_group_typed_by_the_value_it_stores(): void { + $fields = array( + $this->field( 'text', 'name' ), + $this->field( 'checkbox', 'flag' ), + $this->field( 'multiselect', 'colors', array( 'options' => self::COLORS ) ), + $this->field( 'checkboxes', 'days', array( 'options' => self::NUMBERED ) ), + ); + $page = new OptionsPage( + $this->identity, + $this->id, + new RecordedDefinition( fn ( string $page_id, ?string $tab_slug = null ): array => \is_null( $tab_slug ) ? $this->spec( array(), array( 'capability' => 'edit_pages' ) )['page'] : $fields ) + ); + $GLOBALS['pagenow'] = 'options.php'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Runs the registration as options.php does. + $_REQUEST['option_page'] = $this->group; + + $page->register_settings(); + + $registered = get_registered_settings(); + self::assertSame( + array( + "{$this->prefix}_name" => array( $this->group, 'string', false ), + "{$this->prefix}_flag" => array( $this->group, 'string', false ), + "{$this->prefix}_colors" => array( $this->group, 'array', false ), + "{$this->prefix}_days" => array( $this->group, 'array', false ), + ), + \array_map( static fn ( array $setting ): array => array( $setting['group'], $setting['type'], $setting['show_in_rest'] ), \array_intersect_key( $registered, \array_flip( \array_column( $fields, 'id' ) ) ) ) + ); + self::assertSame( 'edit_pages', apply_filters( "option_page_capability_$this->group", 'manage_options' ) ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- Applies core's filter. + } + + public function test_a_request_outside_options_php_registers_no_setting(): void { + $page = new OptionsPage( $this->identity, $this->id, new RecordedDefinition( fn ( string $page_id, ?string $tab_slug = null ): array => \is_null( $tab_slug ) ? $this->spec( array() )['page'] : array( $this->field( 'text', 'name' ) ) ) ); + $GLOBALS['pagenow'] = 'admin-post.php'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Runs the registration as another admin screen does. + $_REQUEST['option_page'] = $this->group; + + $page->register_settings(); + + self::assertArrayNotHasKey( "{$this->prefix}_name", get_registered_settings() ); + } + + public function test_a_tab_without_a_field_to_save_renders_no_form(): void { + $intro = array( + 'type' => 'title', + 'title' => 'Intro', + ); + $form = $this->open( $this->spec( array( $intro ) ), $this->admin ); + $page = self::document( $form['body'] ); + + self::assertSame( 'Acme settings', $page->querySelector( '.wrap h1' )?->textContent ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. + self::assertNull( $page->querySelector( 'form[action="options.php"]' ) ); + self::assertSame( array( 'general' ), $form['asked'] ); + self::assertNotContains( OptionsPage::class . '::get_fields', \array_column( $form['doing_it_wrong'] ?? array(), 0 ) ); + } + public function test_a_first_save_validates_once_and_stores_the_result_without_an_error(): void { - $spec = $this->spec( array( self::field( 'text', 'name', array( 'validate' => 'acme:append' ) ) ) ); - $data = self::form_data( $this->open( $spec, $this->admin )['body'], array( "{$this->option}[name]" => 'v' ) ); + $spec = $this->spec( array( $this->field( 'text', 'name', array( 'validate' => 'acme:append' ) ) ) ); + $data = self::form_data( $this->open( $spec, $this->admin )['body'], array( "{$this->prefix}_name" => 'v' ) ); $saved = $this->save( $spec, $this->admin, $data ); - self::assertSame( array( 'name' => 'v!' ), get_option( $this->option ) ); - self::assertSame( array( array( 'validate', 'name', 'v' ) ), $saved['records'] ); + self::assertSame( 'v!', get_option( "{$this->prefix}_name" ) ); + self::assertSame( array( array( 'validate', "{$this->prefix}_name", 'v' ) ), $saved['records'] ); self::assertSame( array( 'Settings saved.' ), self::notices( $this->follow( $spec, $this->admin, $saved )['body'] ) ); } public function test_a_programmatic_update_outside_options_php_is_stored_as_given(): void { - $value = array( 'name' => ' raw ' ); - $spec = $this->spec( array( self::field( 'text', 'name', array( 'validate' => 'acme:append' ) ) ), array(), array( 'update' => array( $this->option => $value ) ) ); + $spec = $this->spec( array( $this->field( 'text', 'name', array( 'validate' => 'acme:append' ) ) ), array(), array( 'update' => array( "{$this->prefix}_name" => ' raw ' ) ) ); - $request = $this->admin_request( 'GET', "admin-post.php?option_page=$this->option", $this->admin, self::FIXTURE, $spec ); + $request = $this->admin_request( 'GET', "admin-post.php?option_page=$this->group", $this->admin, self::FIXTURE, $spec ); - self::assertSame( $value, get_option( $this->option ) ); + self::assertSame( ' raw ', get_option( "{$this->prefix}_name" ) ); self::assertSame( array(), $request['records'] ); } #[DataProvider( 'parents' )] public function test_a_rejected_field_keeps_its_value_and_its_message_shows_once( string $parent_file ): void { - update_option( $this->option, array( 'name' => 'old' ) ); - $spec = $this->spec( array( self::field( 'text', 'name', array( 'validate' => 'acme:reject' ) ) ), array( 'parent' => $parent_file ) ); - $data = self::form_data( $this->open( $spec, $this->admin )['body'], array( "{$this->option}[name]" => 'too long' ) ); + update_option( "{$this->prefix}_name", 'old' ); + $spec = $this->spec( array( $this->field( 'text', 'name', array( 'validate' => 'acme:reject' ) ) ), array( 'parent' => $parent_file ) ); + $data = self::form_data( $this->open( $spec, $this->admin )['body'], array( "{$this->prefix}_name" => 'too long' ) ); $saved = $this->save( $spec, $this->admin, $data ); - self::assertSame( array( 'name' => 'old' ), get_option( $this->option ) ); + self::assertSame( 'old', get_option( "{$this->prefix}_name" ) ); self::assertSame( array( 'Name was not saved and keeps its previous value. The other changes were saved. Use at most four characters.' ), self::notices( $this->follow( $spec, $this->admin, $saved )['body'] ) ); } + public function test_a_rejected_field_never_stored_stays_absent(): void { + $spec = $this->spec( array( $this->field( 'text', 'name', array( 'validate' => 'acme:reject' ) ) ) ); + $data = self::form_data( $this->open( $spec, $this->admin )['body'], array( "{$this->prefix}_name" => 'too long' ) ); + + $this->save( $spec, $this->admin, $data ); + + self::assertFalse( get_option( "{$this->prefix}_name" ) ); + } + #[DataProvider( 'parents' )] public function test_a_valid_save_shows_settings_saved_once( string $parent_file ): void { - $spec = $this->spec( array( self::field( 'text', 'name' ) ), array( 'parent' => $parent_file ) ); - $data = self::form_data( $this->open( $spec, $this->admin )['body'], array( "{$this->option}[name]" => 'new' ) ); + $spec = $this->spec( array( $this->field( 'text', 'name' ) ), array( 'parent' => $parent_file ) ); + $data = self::form_data( $this->open( $spec, $this->admin )['body'], array( "{$this->prefix}_name" => 'new' ) ); $saved = $this->save( $spec, $this->admin, $data ); - self::assertSame( array( 'name' => 'new' ), get_option( $this->option ) ); + self::assertSame( 'new', get_option( "{$this->prefix}_name" ) ); self::assertSame( array( 'Settings saved.' ), self::notices( $this->follow( $spec, $this->admin, $saved )['body'] ) ); } public function test_a_rejection_without_a_message_says_that_the_value_is_not_valid(): void { - update_option( $this->option, array( 'name' => 'old' ) ); - $spec = $this->spec( array( self::field( 'text', 'name', array( 'validate' => 'acme:reject-silently' ) ) ) ); - $data = self::form_data( $this->open( $spec, $this->admin )['body'], array( "{$this->option}[name]" => 'new' ) ); + update_option( "{$this->prefix}_name", 'old' ); + $spec = $this->spec( array( $this->field( 'text', 'name', array( 'validate' => 'acme:reject-silently' ) ) ) ); + $data = self::form_data( $this->open( $spec, $this->admin )['body'], array( "{$this->prefix}_name" => 'new' ) ); $saved = $this->save( $spec, $this->admin, $data ); - self::assertSame( array( 'name' => 'old' ), get_option( $this->option ) ); + self::assertSame( 'old', get_option( "{$this->prefix}_name" ) ); self::assertSame( array( 'Name was not saved because its value is not valid, and keeps its previous value. The other changes were saved.' ), self::notices( $this->follow( $spec, $this->admin, $saved )['body'] ) ); } public function test_a_built_in_rejection_carries_what_to_enter(): void { - update_option( $this->option, array( 'email' => 'ops@example.com' ) ); - $spec = $this->spec( array( self::field( 'email', 'email' ) ) ); - $data = self::form_data( $this->open( $spec, $this->admin )['body'], array( "{$this->option}[email]" => 'not an email' ) ); + update_option( "{$this->prefix}_email", 'ops@example.com' ); + $spec = $this->spec( array( $this->field( 'email', 'email' ) ) ); + $data = self::form_data( $this->open( $spec, $this->admin )['body'], array( "{$this->prefix}_email" => 'not an email' ) ); $saved = $this->save( $spec, $this->admin, $data ); - self::assertSame( array( 'email' => 'ops@example.com' ), get_option( $this->option ) ); + self::assertSame( 'ops@example.com', get_option( "{$this->prefix}_email" ) ); self::assertSame( array( 'Email was not saved and keeps its previous value. The other changes were saved. Enter a valid email address, such as name@example.com.' ), self::notices( $this->follow( $spec, $this->admin, $saved )['body'] ) ); } public function test_titles_and_messages_print_as_text_and_an_untitled_field_is_labelled_and_named_by_its_id(): void { + $site = "{$this->prefix}_site"; $spec = $this->spec( array( - self::field( 'email', 'email', array( 'title' => 'Email' ) ), + $this->field( 'email', 'email', array( 'title' => 'Email' ) ), array( 'type' => 'url', - 'id' => 'site', + 'id' => $site, ), ) ); @@ -198,20 +333,20 @@ public function test_titles_and_messages_print_as_text_and_an_untitled_field_is_ $data = self::form_data( $form, array( - "{$this->option}[email]" => 'not an email', - "{$this->option}[site]" => 'javascript:alert(1)', + "{$this->prefix}_email" => 'not an email', + $site => 'javascript:alert(1)', ) ); $page = $this->follow( $spec, $this->admin, $this->save( $spec, $this->admin, $data ) )['body']; - self::assertSame( array( 'Email', 'site' ), self::row_titles( $form ) ); - self::assertSame( "$this->option.site", self::document( $form )->querySelectorAll( 'form tr' )->item( 1 )?->querySelector( 'th label' )?->getAttribute( 'for' ) ); + self::assertSame( array( 'Email', $site ), self::row_titles( $form ) ); + self::assertSame( $site, self::document( $form )->querySelectorAll( 'form tr' )->item( 1 )?->querySelector( 'th label' )?->getAttribute( 'for' ) ); self::assertNull( self::document( $form )->querySelector( 'form em' ) ); self::assertSame( array( 'Email was not saved and keeps its previous value. The other changes were saved. Enter a valid email address, such as name@example.com.', - 'site was not saved and keeps its previous value. The other changes were saved. Enter a valid URL, such as https://example.com.', + "$site was not saved and keeps its previous value. The other changes were saved. Enter a valid URL, such as https://example.com.", ), self::notices( $page ) ); @@ -220,58 +355,43 @@ public function test_titles_and_messages_print_as_text_and_an_untitled_field_is_ #[DataProvider( 'multi_value_types' )] public function test_a_fully_cleared_multi_value_field_stores_an_empty_array( string $type ): void { - update_option( $this->option, array( 'colors' => array( 'red', 'blue' ) ) ); - $spec = $this->spec( array( self::field( $type, 'colors', array( 'options' => self::COLORS ) ) ) ); - $data = self::form_data( $this->open( $spec, $this->admin )['body'], array( "{$this->option}[colors]" => array() ) ); + update_option( "{$this->prefix}_colors", array( 'red', 'blue' ) ); + $spec = $this->spec( array( $this->field( $type, 'colors', array( 'options' => self::COLORS ) ) ) ); + $data = self::form_data( $this->open( $spec, $this->admin )['body'], array( "{$this->prefix}_colors" => array() ) ); $this->save( $spec, $this->admin, $data ); - self::assertSame( array( 'colors' => array() ), get_option( $this->option ) ); + self::assertSame( array(), get_option( "{$this->prefix}_colors" ) ); } public function test_an_unchecked_checkbox_stores_no_while_a_text_field_posts(): void { - update_option( - $this->option, - array( - 'alpha' => 'yes', - 'beta' => 'yes', - 'name' => 'old', - ) - ); - $spec = $this->spec( array( self::field( 'checkbox', 'alpha' ), self::field( 'checkbox', 'beta' ), self::field( 'text', 'name' ) ) ); + update_option( "{$this->prefix}_alpha", 'yes' ); + update_option( "{$this->prefix}_beta", 'yes' ); + update_option( "{$this->prefix}_name", 'old' ); + $spec = $this->spec( array( $this->field( 'checkbox', 'alpha' ), $this->field( 'checkbox', 'beta' ), $this->field( 'text', 'name' ) ) ); $data = self::form_data( $this->open( $spec, $this->admin )['body'], array( - "{$this->option}[beta]" => false, - "{$this->option}[name]" => 'new', + "{$this->prefix}_beta" => false, + "{$this->prefix}_name" => 'new', ) ); $this->save( $spec, $this->admin, $data ); - self::assertSame( - array( - 'alpha' => 'yes', - 'beta' => 'no', - 'name' => 'new', - ), - get_option( $this->option ) - ); + self::assertSame( array( 'yes', 'no', 'new' ), $this->stored( 'alpha', 'beta', 'name' ) ); } public function test_a_disabled_field_keeps_its_value_and_renders_no_hidden_input(): void { - $stored = array( - 'name' => 'old', - 'agree' => 'yes', - 'colors' => array( 'red' ), - 'other' => 'old', - ); - update_option( $this->option, $stored ); + update_option( "{$this->prefix}_name", 'old' ); + update_option( "{$this->prefix}_agree", 'yes' ); + update_option( "{$this->prefix}_colors", array( 'red' ) ); + update_option( "{$this->prefix}_other", 'old' ); $spec = $this->spec( array( - self::field( 'text', 'name', array( 'custom_attributes' => array( 'disabled' => 'disabled' ) ) ), - self::field( 'checkbox', 'agree', array( 'disabled' => true ) ), - self::field( + $this->field( 'text', 'name', array( 'custom_attributes' => array( 'disabled' => 'disabled' ) ) ), + $this->field( 'checkbox', 'agree', array( 'disabled' => true ) ), + $this->field( 'multiselect', 'colors', array( @@ -279,23 +399,23 @@ public function test_a_disabled_field_keeps_its_value_and_renders_no_hidden_inpu 'custom_attributes' => array( 'disabled' => 'disabled' ), ) ), - self::field( 'text', 'other' ), + $this->field( 'text', 'other' ), ) ); $form = $this->open( $spec, $this->admin )['body']; - $this->save( $spec, $this->admin, self::form_data( $form, array( "{$this->option}[other]" => 'new' ) ) ); + $this->save( $spec, $this->admin, self::form_data( $form, array( "{$this->prefix}_other" => 'new' ) ) ); - self::assertSame( \array_replace( $stored, array( 'other' => 'new' ) ), get_option( $this->option ) ); - self::assertSame( array( 'other' ), \array_keys( self::form_data( $form )[ $this->option ] ) ); - self::assertCount( 0, self::document( $form )->querySelectorAll( 'form input[type="hidden"][name^="' . $this->option . '["]' ) ); + self::assertSame( array( 'old', 'yes', array( 'red' ), 'new' ), $this->stored( 'name', 'agree', 'colors', 'other' ) ); + self::assertSame( array( "{$this->prefix}_other" ), \array_values( \array_filter( \array_keys( self::form_data( $form ) ), fn ( string $name ): bool => \str_starts_with( $name, $this->prefix ) ) ) ); + self::assertCount( 0, self::document( $form )->querySelectorAll( 'form input[type="hidden"][name^="' . $this->prefix . '"]' ) ); } - public function test_a_tab_whose_every_control_is_disabled_leaves_the_row_unchanged(): void { - update_option( $this->option, array( 'name' => 'old' ) ); + public function test_a_tab_whose_every_control_is_disabled_leaves_its_rows_unchanged(): void { + update_option( "{$this->prefix}_name", 'old' ); $spec = $this->spec( array( - self::field( + $this->field( 'text', 'name', array( @@ -309,41 +429,41 @@ public function test_a_tab_whose_every_control_is_disabled_leaves_the_row_unchan $saved = $this->save( $spec, $this->admin, $data ); - self::assertArrayNotHasKey( $this->option, $data ); - self::assertSame( array( 'name' => 'old' ), get_option( $this->option ) ); + self::assertArrayNotHasKey( "{$this->prefix}_name", $data ); + self::assertSame( 'old', get_option( "{$this->prefix}_name" ) ); self::assertSame( array(), $saved['records'] ); } public function test_a_capability_above_manage_options_refuses_an_administrators_save(): void { - $spec = $this->spec( array( self::field( 'text', 'name' ) ), array( 'capability' => 'acme_manage_settings' ) ); + $spec = $this->spec( array( $this->field( 'text', 'name' ) ), array( 'capability' => 'acme_manage_settings' ) ); $data = array( - 'option_page' => $this->option, - 'action' => 'update', - $this->option => array( 'name' => 'new' ), + 'option_page' => $this->group, + 'action' => 'update', + "{$this->prefix}_name" => 'new', ); $saved = $this->save( $spec, $this->admin, $data ); self::assertSame( 403, $saved['died']['status'] ?? null ); self::assertStringContainsString( 'Sorry, you are not allowed to manage options for this site.', $saved['died']['message'] ?? '' ); - self::assertFalse( get_option( $this->option ) ); + self::assertFalse( get_option( "{$this->prefix}_name" ) ); } public function test_a_capability_below_manage_options_lets_its_holder_save(): void { - $spec = $this->spec( array( self::field( 'text', 'name' ) ), array( 'capability' => 'edit_pages' ) ); - $data = self::form_data( $this->open( $spec, $this->editor )['body'], array( "{$this->option}[name]" => 'new' ) ); + $spec = $this->spec( array( $this->field( 'text', 'name' ) ), array( 'capability' => 'edit_pages' ) ); + $data = self::form_data( $this->open( $spec, $this->editor )['body'], array( "{$this->prefix}_name" => 'new' ) ); $this->save( $spec, $this->editor, $data ); - self::assertSame( array( 'name' => 'new' ), get_option( $this->option ) ); + self::assertSame( 'new', get_option( "{$this->prefix}_name" ) ); } #[DataProvider( 'relocated_option_pages' )] public function test_an_option_page_outside_the_post_body_or_padded_still_validates( string $variant ): void { - update_option( $this->option, array( 'size' => 's' ) ); - $spec = $this->spec( + update_option( "{$this->prefix}_size", 's' ); + $spec = $this->spec( array( - self::field( + $this->field( 'select', 'size', array( @@ -355,38 +475,38 @@ public function test_an_option_page_outside_the_post_body_or_padded_still_valida ), ) ); - $data = self::form_data( $this->open( $spec, $this->admin )['body'] ); - $data[ $this->option ]['size'] = 'xl'; - $query = ''; + $data = self::form_data( $this->open( $spec, $this->admin )['body'] ); + $data[ "{$this->prefix}_size" ] = 'xl'; + $query = ''; if ( 'query-string' === $variant ) { unset( $data['option_page'] ); - $query = '?option_page=' . \rawurlencode( $this->option ); + $query = '?option_page=' . \rawurlencode( $this->group ); } else { - $data['option_page'] = " $this->option "; + $data['option_page'] = " $this->group "; } $saved = $this->save( $spec, $this->admin, $data, $query ); self::assertNull( $saved['died'] ); - self::assertSame( array( 'size' => 's' ), get_option( $this->option ) ); + self::assertSame( 's', get_option( "{$this->prefix}_size" ) ); } #[DataProvider( 'multi_value_types' )] public function test_two_selections_store_exactly_those_two( string $type ): void { - $spec = $this->spec( array( self::field( $type, 'colors', array( 'options' => self::COLORS ) ) ) ); - $data = self::form_data( $this->open( $spec, $this->admin )['body'], array( "{$this->option}[colors]" => array( 'red', 'teal' ) ) ); + $spec = $this->spec( array( $this->field( $type, 'colors', array( 'options' => self::COLORS ) ) ) ); + $data = self::form_data( $this->open( $spec, $this->admin )['body'], array( "{$this->prefix}_colors" => array( 'red', 'teal' ) ) ); $this->save( $spec, $this->admin, $data ); - self::assertSame( array( 'colors' => array( 'red', 'teal' ) ), get_option( $this->option ) ); + self::assertSame( array( 'red', 'teal' ), get_option( "{$this->prefix}_colors" ) ); } #[DataProvider( 'multi_value_types' )] public function test_a_cleared_multi_value_field_reaches_validate_as_an_empty_array( string $type ): void { - update_option( $this->option, array( 'colors' => array( 'red' ) ) ); + update_option( "{$this->prefix}_colors", array( 'red' ) ); $spec = $this->spec( array( - self::field( + $this->field( $type, 'colors', array( @@ -396,39 +516,36 @@ public function test_a_cleared_multi_value_field_reaches_validate_as_an_empty_ar ), ) ); - $data = self::form_data( $this->open( $spec, $this->admin )['body'], array( "{$this->option}[colors]" => array() ) ); + $data = self::form_data( $this->open( $spec, $this->admin )['body'], array( "{$this->prefix}_colors" => array() ) ); $saved = $this->save( $spec, $this->admin, $data ); - self::assertSame( array( array( 'validate', 'colors', array() ) ), $saved['records'] ); - self::assertSame( array( 'colors' => array() ), get_option( $this->option ) ); + self::assertSame( array( array( 'validate', "{$this->prefix}_colors", array() ) ), $saved['records'] ); + self::assertSame( array(), get_option( "{$this->prefix}_colors" ) ); } public function test_a_tampered_value_for_a_disabled_field_is_ignored(): void { - $stored = array( - 'name' => 'old', - 'agree' => 'no', - 'other' => 'old', - ); - update_option( $this->option, $stored ); - $spec = $this->spec( + update_option( "{$this->prefix}_name", 'old' ); + update_option( "{$this->prefix}_agree", 'no' ); + update_option( "{$this->prefix}_other", 'old' ); + $spec = $this->spec( array( - self::field( 'text', 'name', array( 'custom_attributes' => array( 'disabled' => 'disabled' ) ) ), - self::field( 'checkbox', 'agree', array( 'disabled' => true ) ), - self::field( 'text', 'other' ), + $this->field( 'text', 'name', array( 'custom_attributes' => array( 'disabled' => 'disabled' ) ) ), + $this->field( 'checkbox', 'agree', array( 'disabled' => true ) ), + $this->field( 'text', 'other' ), ) ); - $data = self::form_data( $this->open( $spec, $this->admin )['body'] ); - $data[ $this->option ]['name'] = 'tampered'; - $data[ $this->option ]['agree'] = 'yes'; + $data = self::form_data( $this->open( $spec, $this->admin )['body'] ); + $data[ "{$this->prefix}_name" ] = 'tampered'; + $data[ "{$this->prefix}_agree" ] = 'yes'; $this->save( $spec, $this->admin, $data ); - self::assertSame( $stored, get_option( $this->option ) ); + self::assertSame( array( 'old', 'no', 'old' ), $this->stored( 'name', 'agree', 'other' ) ); } public function test_the_submenu_entry_and_the_page_exist_only_for_users_holding_the_capability(): void { - $spec = $this->spec( array( self::field( 'text', 'name' ) ), array( 'parent' => 'tools.php' ) ); + $spec = $this->spec( array( $this->field( 'text', 'name' ) ), array( 'parent' => 'tools.php' ) ); $link = '#menu-tools a[href$="?page=' . $this->id . '"]'; $admin_menu = $this->admin_request( 'GET', 'tools.php', $this->admin, self::FIXTURE, $spec )['body']; @@ -441,14 +558,6 @@ public function test_the_submenu_entry_and_the_page_exist_only_for_users_holding self::assertStringNotContainsString( 'Acme settings', $editor_page['body'] ); } - public function test_a_label_points_at_its_control_when_the_field_id_holds_an_entity(): void { - $form = self::document( $this->open( $this->spec( array( self::field( 'text', 'r&d' ) ) ), $this->admin )['body'] )->querySelector( 'form[action="options.php"]' ); - - $id = (string) $form?->querySelector( 'td input[type=text]' )?->getAttribute( 'id' ); - self::assertSame( "$this->option.r&d", $id ); - self::assertSame( $id, $form?->querySelector( 'th label' )?->getAttribute( 'for' ) ); - } - public function test_a_form_posted_back_with_one_edit_changes_only_that_field_and_labels_point_at_single_controls(): void { $stored = array( 'name' => 'old', @@ -464,28 +573,30 @@ public function test_a_form_posted_back_with_one_edit_changes_only_that_field_an 'days' => array( '2' ), 'agree' => 'yes', ); - update_option( $this->option, $stored ); + foreach ( $stored as $name => $value ) { + update_option( "{$this->prefix}_$name", $value ); + } $spec = $this->spec( array( - self::field( 'text', 'name' ), - self::field( 'number', 'count' ), - self::field( 'email', 'email' ), - self::field( 'url', 'site' ), - self::field( 'textarea', 'notes' ), - self::field( 'checkbox', 'on' ), - self::field( 'checkbox', 'off' ), - self::field( 'select', 'size', array( 'options' => self::COLORS ) ), - self::field( 'radio', 'tone', array( 'options' => self::NUMBERED ) ), - self::field( 'multiselect', 'colors', array( 'options' => self::NUMBERED ) ), - self::field( 'checkboxes', 'days', array( 'options' => self::NUMBERED ) ), - self::field( 'checkbox', 'agree', array( 'desc' => 'I agree' ) ), + $this->field( 'text', 'name' ), + $this->field( 'number', 'count' ), + $this->field( 'email', 'email' ), + $this->field( 'url', 'site' ), + $this->field( 'textarea', 'notes' ), + $this->field( 'checkbox', 'on' ), + $this->field( 'checkbox', 'off' ), + $this->field( 'select', 'size', array( 'options' => self::COLORS ) ), + $this->field( 'radio', 'tone', array( 'options' => self::NUMBERED ) ), + $this->field( 'multiselect', 'colors', array( 'options' => self::NUMBERED ) ), + $this->field( 'checkboxes', 'days', array( 'options' => self::NUMBERED ) ), + $this->field( 'checkbox', 'agree', array( 'desc' => 'I agree' ) ), ) ); $form = $this->open( $spec, $this->admin )['body']; - $this->save( $spec, $this->admin, self::form_data( $form, array( "{$this->option}[name]" => 'new' ) ) ); + $this->save( $spec, $this->admin, self::form_data( $form, array( "{$this->prefix}_name" => 'new' ) ) ); - self::assertSame( \array_replace( $stored, array( 'name' => 'new' ) ), get_option( $this->option ) ); + self::assertSame( \array_values( \array_replace( $stored, array( 'name' => 'new' ) ) ), $this->stored( ...\array_keys( $stored ) ) ); $labels = array(); foreach ( self::document( $form )->querySelectorAll( 'form tr' ) as $row ) { $for = $row->querySelector( 'th label' )?->getAttribute( 'for' ); @@ -496,16 +607,16 @@ public function test_a_form_posted_back_with_one_edit_changes_only_that_field_an } self::assertSame( array( - 'Name' => "$this->option.name", - 'Count' => "$this->option.count", - 'Email' => "$this->option.email", - 'Site' => "$this->option.site", - 'Notes' => "$this->option.notes", - 'On' => "$this->option.on", - 'Off' => "$this->option.off", - 'Size' => "$this->option.size", + 'Name' => "{$this->prefix}_name", + 'Count' => "{$this->prefix}_count", + 'Email' => "{$this->prefix}_email", + 'Site' => "{$this->prefix}_site", + 'Notes' => "{$this->prefix}_notes", + 'On' => "{$this->prefix}_on", + 'Off' => "{$this->prefix}_off", + 'Size' => "{$this->prefix}_size", 'Tone' => null, - 'Colors' => "$this->option.colors", + 'Colors' => "{$this->prefix}_colors", 'Days' => null, 'Agree' => null, ), @@ -513,124 +624,90 @@ public function test_a_form_posted_back_with_one_edit_changes_only_that_field_an ); } - public function test_a_submitted_id_outside_the_definition_is_not_stored(): void { - $spec = $this->spec( array( self::field( 'text', 'name' ) ) ); - $data = self::form_data( $this->open( $spec, $this->admin )['body'], array( "{$this->option}[name]" => 'new' ) ); - $data[ $this->option ]['injected'] = 'x'; + public function test_a_submitted_id_outside_the_tab_is_not_stored(): void { + $spec = $this->spec( array( $this->field( 'text', 'name' ) ) ); + $data = self::form_data( $this->open( $spec, $this->admin )['body'], array( "{$this->prefix}_name" => 'new' ) ) + array( "{$this->prefix}_injected" => 'x' ); $this->save( $spec, $this->admin, $data ); - self::assertSame( array( 'name' => 'new' ), get_option( $this->option ) ); - } - - public function test_a_stored_id_absent_from_the_definition_keeps_its_value(): void { - update_option( - $this->option, - array( - 'name' => 'old', - 'retired' => 'kept', - ) - ); - $spec = $this->spec( array( self::field( 'text', 'name' ) ) ); - $data = self::form_data( $this->open( $spec, $this->admin )['body'], array( "{$this->option}[name]" => 'new' ) ); - - $this->save( $spec, $this->admin, $data ); - - self::assertSame( - array( - 'name' => 'new', - 'retired' => 'kept', - ), - get_option( $this->option ) - ); + self::assertSame( 'new', get_option( "{$this->prefix}_name" ) ); + self::assertFalse( get_option( "{$this->prefix}_injected" ) ); } - public function test_tabs_and_fields_contributed_through_the_tabs_filter_render_and_save(): void { - $addon = "{$this->prefix}_addon"; - $spec = $this->spec( - array( self::field( 'text', 'name' ) ), + public function test_tabs_and_fields_contributed_through_the_filters_render_and_save(): void { + $spec = $this->spec( + array( $this->field( 'text', 'name' ) ), array(), array( 'contributed' => array( - 'fields' => array( 'general' => array( self::field( 'text', 'extra', array( 'validate' => 'acme:append' ) ) ) ), - 'tabs' => array( self::tab( 'addon', $addon, array( self::field( 'text', 'key' ) ) ) ), + 'tabs' => array( self::tab( 'addon' ) ), + 'fields' => array( + 'general' => array( $this->field( 'text', 'extra', array( 'validate' => 'acme:append' ) ) ), + 'addon' => array( $this->field( 'text', 'key' ) ), + ), ), ) ); $general = $this->open( $spec, $this->admin )['body']; - $this->save( $spec, $this->admin, self::form_data( $general, array( "{$this->option}[extra]" => 'e' ) ) ); - $this->save( $spec, $this->admin, self::form_data( $this->open( $spec, $this->admin, 'addon' )['body'], array( "{$addon}[key]" => 'k' ) ) ); + $this->save( $spec, $this->admin, self::form_data( $general, array( "{$this->prefix}_extra" => 'e' ) ) ); + $this->save( $spec, $this->admin, self::form_data( $this->open( $spec, $this->admin, 'addon' )['body'], array( "{$this->prefix}_key" => 'k' ) ) ); self::assertSame( array( 'General', 'Addon' ), self::tab_titles( $general ) ); - self::assertSame( - array( - 'name' => '', - 'extra' => 'e!', - ), - get_option( $this->option ) - ); - self::assertSame( array( 'key' => 'k' ), get_option( $addon ) ); + self::assertSame( array( '', 'e!', 'k' ), $this->stored( 'name', 'extra', 'key' ) ); } public function test_each_tab_shows_its_own_form_and_an_unknown_tab_shows_the_first(): void { - $second = "{$this->prefix}_second"; - $spec = $this->spec( - array( self::field( 'text', 'name' ) ), - array( - 'tabs' => array( - self::tab( 'general', $this->option, array( self::field( 'text', 'name' ) ) ), - self::tab( 'second', $second, array( self::field( 'text', 'other' ) ) ), - ), - ) + $spec = $this->spec( + array( $this->field( 'text', 'name' ) ), + array( 'tabs' => array( self::tab( 'general' ), self::tab( 'second' ) ) ), + array( 'fields' => array( 'second' => array( $this->field( 'text', 'other' ) ) ) ) ); - $second_tab = self::document( $this->open( $spec, $this->admin, 'second' )['body'] ); + $second = $this->open( $spec, $this->admin, 'second' )['body']; + $second_tab = self::document( $second ); $unknown = self::document( $this->open( $spec, $this->admin, 'missing' )['body'] ); - $single = self::document( $this->open( $this->spec( array( self::field( 'text', 'name' ) ) ), $this->admin )['body'] ); + $single = self::document( $this->open( $this->spec( array( $this->field( 'text', 'name' ) ) ), $this->admin )['body'] ); - self::assertSame( $second, $second_tab->querySelector( 'form input[name="option_page"]' )?->getAttribute( 'value' ) ); + self::assertSame( "{$this->id}_second", $second_tab->querySelector( 'form input[name="option_page"]' )?->getAttribute( 'value' ) ); + self::assertSame( array( 'Other' ), self::row_titles( $second ) ); self::assertSame( 'Second', $second_tab->querySelector( '.nav-tab[aria-current="page"]' )?->textContent ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. - self::assertSame( $this->option, $unknown->querySelector( 'form input[name="option_page"]' )?->getAttribute( 'value' ) ); + self::assertSame( $this->group, $unknown->querySelector( 'form input[name="option_page"]' )?->getAttribute( 'value' ) ); self::assertSame( 'General', $unknown->querySelector( '.nav-tab[aria-current="page"]' )?->textContent ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. self::assertNull( $single->querySelector( '.nav-tab' ) ); } - public function test_saving_the_second_of_two_tabs_on_one_option_keeps_the_first_tabs_field(): void { + public function test_saving_one_tab_leaves_the_other_tabs_fields_as_they_are(): void { + update_option( "{$this->prefix}_name", 'first' ); $spec = $this->spec( - array( self::field( 'text', 'name' ) ), - array( - 'tabs' => array( - self::tab( 'general', $this->option, array( self::field( 'text', 'name' ) ) ), - self::tab( 'second', $this->option, array( self::field( 'text', 'other' ) ) ), - ), - ) + array( $this->field( 'text', 'name' ) ), + array( 'tabs' => array( self::tab( 'general' ), self::tab( 'second' ) ) ), + array( 'fields' => array( 'second' => array( $this->field( 'text', 'other' ) ) ) ) ); - $this->save( $spec, $this->admin, self::form_data( $this->open( $spec, $this->admin )['body'], array( "{$this->option}[name]" => 'first' ) ) ); - $this->save( $spec, $this->admin, self::form_data( $this->open( $spec, $this->admin, 'second' )['body'], array( "{$this->option}[other]" => 'second' ) ) ); + $saved = $this->save( $spec, $this->admin, self::form_data( $this->open( $spec, $this->admin, 'second' )['body'], array( "{$this->prefix}_other" => 'second' ) ) ); - self::assertSame( - array( - 'name' => 'first', - 'other' => 'second', - ), - get_option( $this->option ) - ); + self::assertNull( $saved['died'] ); + self::assertSame( array( 'first', 'second' ), $this->stored( 'name', 'other' ) ); } protected function spec( array $fields, array $page = array(), array $extra = array() ): array { - return array( - 'id' => $this->id, - 'slug' => $this->slug, - 'page' => $page + array( - 'parent' => 'options-general.php', - 'capability' => 'manage_options', - 'page_title' => 'Acme settings', - 'menu_title' => 'Acme', - 'tabs' => array( self::tab( 'general', $this->option, $fields ) ), + return \array_replace_recursive( + array( + 'id' => $this->id, + 'slug' => $this->slug, + 'prefix' => $this->prefix, + 'page' => $page + array( + 'parent' => 'options-general.php', + 'capability' => 'manage_options', + 'page_title' => 'Acme settings', + 'menu_title' => 'Acme', + 'tabs' => array( self::tab( 'general' ) ), + ), + 'fields' => array( 'general' => $fields ), ), - ) + $extra; + $extra + ); } protected function open( array $spec, int $user_id, string $tab = '' ): array { @@ -650,20 +727,23 @@ protected function follow( array $spec, int $user_id, array $saved ): array { return $this->admin_request( 'GET', \explode( '/wp-admin/', $saved['location'], 2 )[1], $user_id, self::FIXTURE, $spec ); } - protected static function tab( string $slug, string $option, array $fields ): array { + // Reads each named field's row, as get_option() returns it. + protected function stored( string ...$names ): array { + return \array_map( fn ( string $name ): mixed => get_option( "{$this->prefix}_$name" ), $names ); + } + + protected static function tab( string $slug ): array { return array( - 'slug' => $slug, - 'title' => \ucfirst( $slug ), - 'option' => $option, - 'fields' => $fields, + 'slug' => $slug, + 'title' => \ucfirst( $slug ), ); } - protected static function field( string $type, string $id, array $keys = array() ): array { + protected function field( string $type, string $name, array $keys = array() ): array { return $keys + array( 'type' => $type, - 'id' => $id, - 'title' => \ucfirst( $id ), + 'id' => "{$this->prefix}_$name", + 'title' => \ucfirst( $name ), ); } diff --git a/tests/Integration/Settings/FormsSanitizeFieldsTest.php b/tests/Integration/Settings/FormsSanitizeFieldsTest.php index d9e58b6..8318627 100644 --- a/tests/Integration/Settings/FormsSanitizeFieldsTest.php +++ b/tests/Integration/Settings/FormsSanitizeFieldsTest.php @@ -40,8 +40,8 @@ public static function accepted(): array { 'number-integer' => array( 'number', '12', '12' ), 'number-is-trimmed' => array( 'number', ' 4 ', '4' ), 'number-negative-decimal' => array( 'number', '-1.5', '-1.5' ), - 'number-leading-point' => array( 'number', '.5', '.5' ), - 'number-exponent' => array( 'number', '1e3', '1e3' ), + 'number-leading-point' => array( 'number', '.5', '0.5' ), + 'number-exponent' => array( 'number', '1e3', '1000' ), 'number-cleared' => array( 'number', '', '' ), 'number-from-an-int' => array( 'number', 5, '5' ), 'number-from-a-float' => array( 'number', 1.5, '1.5' ), @@ -122,6 +122,20 @@ public function test_a_radio_rejects_a_disabled_choice_and_accepts_an_enabled_on self::assertSame( '1', self::sanitize( $field, '1' ) ); } + public function test_a_translated_message_holding_a_percent_sign_is_returned_as_it_is(): void { + $translate = static fn ( string $translation, string $text ): string => 'Enter a valid value.' === $text ? 'Saisissez 100% des chiffres.' : $translation; + add_filter( 'gettext_wp-framework', $translate, 10, 2 ); + + try { + $result = self::sanitize( self::field( 'text' ), array( 'a' ) ); + } finally { + remove_filter( 'gettext_wp-framework', $translate ); + } + + self::assertInstanceOf( \WP_Error::class, $result ); + self::assertSame( 'Saisissez 100% des chiffres.', $result->get_error_message() ); + } + public function test_a_custom_type_without_validate_is_rejected(): void { $field = array( 'type' => 'text', diff --git a/tests/Integration/Settings/FormsSanitizeNumberTest.php b/tests/Integration/Settings/FormsSanitizeNumberTest.php new file mode 100644 index 0000000..4db16bd --- /dev/null +++ b/tests/Integration/Settings/FormsSanitizeNumberTest.php @@ -0,0 +1,113 @@ + array( array(), '.5', '0.5' ), + 'canonical-exponent' => array( array(), '1e3', '1000' ), + 'canonical-negative-exponent' => array( array(), '25E-1', '2.5' ), + 'canonical-small-fraction' => array( array(), '1.5e-7', '0.00000015' ), + 'canonical-trailing-zeros' => array( array(), '2.50', '2.5' ), + 'canonical-leading-zeros' => array( array(), '007', '7' ), + 'canonical-whole-decimal' => array( array(), '3.0', '3' ), + 'canonical-negative-zero' => array( array(), '-0.0', '0' ), + 'canonical-float' => array( array(), 2.0, '2' ), + 'cleared-below-its-min' => array( array( 'min' => 1 ), '', '' ), + 'decimal-within-bounds-no-step' => array( + array( + 'min' => 1, + 'max' => 10, + ), + '2.5', + '2.5', + ), + 'at-its-min' => array( array( 'min' => '1' ), '1', '1' ), + 'at-its-max' => array( array( 'max' => '10' ), '10.0', '10' ), + 'whole-step-from-its-min' => array( + array( + 'min' => 1, + 'step' => 2, + ), + '5', + '5', + ), + 'whole-step-from-zero' => array( array( 'step' => 5 ), '-10', '-10' ), + 'whole-step-stores-integer-text' => array( array( 'step' => '1' ), '4.000', '4' ), + 'whole-step-from-an-int' => array( array( 'step' => 1 ), 7, '7' ), + 'decimal-step-checked-exactly' => array( array( 'step' => '0.01' ), '0.3', '0.3' ), + 'decimal-step-from-a-decimal-min' => array( + array( + 'min' => '0.5', + 'step' => '0.25', + ), + '1.25', + '1.25', + ), + 'step-any' => array( array( 'step' => 'any' ), '3.14159', '3.14159' ), + 'step-any-in-capitals' => array( array( 'step' => 'ANY' ), '1.5', '1.5' ), + 'unparseable-min-ignored' => array( array( 'min' => 'abc' ), '-5', '-5' ), + 'empty-max-ignored' => array( array( 'max' => '' ), '99', '99' ), + 'zero-step-counts-as-one' => array( array( 'step' => '0' ), '4', '4' ), + 'negative-step-counts-as-one' => array( array( 'step' => -2 ), '3', '3' ), + 'unparseable-step-counts-as-one' => array( array( 'step' => 'big' ), '6', '6' ), + 'whole-step-near-the-integer-edge' => array( array( 'step' => 1 ), '9223372036854775807', '9223372036854775807' ), + ); + } + + #[DataProvider( 'accepted' )] + public function test_a_number_within_its_bounds_and_on_its_grid_stores_as_canonical_text( array $attributes, mixed $raw, string $expected ): void { + self::assertSame( $expected, self::sanitize( $attributes, $raw ) ); + } + + public static function rejected(): array { + return array( + 'below-its-min' => array( array( 'min' => 1 ), '0', 'number_below_min', 'Enter a number of at least 1.' ), + 'below-a-decimal-min' => array( array( 'min' => '0.50' ), '0.25', 'number_below_min', 'Enter a number of at least 0.5.' ), + 'below-a-min-named-in-capitals' => array( array( 'MIN' => 5 ), '1', 'number_below_min', 'Enter a number of at least 5.' ), + 'above-its-max' => array( array( 'max' => 10 ), '10.5', 'number_above_max', 'Enter a number of at most 10.' ), + 'off-a-whole-step' => array( array( 'step' => 1 ), '2.5', 'number_off_step', 'Enter a number in steps of 1 from 0.' ), + 'off-a-step-from-its-min' => array( + array( + 'min' => 1, + 'step' => 2, + ), + '4', + 'number_off_step', + 'Enter a number in steps of 2 from 1.', + ), + 'off-a-decimal-step' => array( array( 'step' => '0.01' ), '0.305', 'number_off_step', 'Enter a number in steps of 0.01 from 0.' ), + 'off-the-step-a-zero-step-means' => array( array( 'step' => '0' ), '4.5', 'number_off_step', 'Enter a number in steps of 1 from 0.' ), + 'beyond-the-integer-range' => array( array( 'step' => 1 ), '1e19', 'invalid_number', 'Enter a number, such as 12 or 3.5.' ), + 'beyond-the-range-at-its-decimal' => array( array( 'step' => '0.01' ), '100000000000000000', 'invalid_number', 'Enter a number, such as 12 or 3.5.' ), + 'too-small-to-hold' => array( array(), '1e-400', 'invalid_number', 'Enter a number, such as 12 or 3.5.' ), + ); + } + + #[DataProvider( 'rejected' )] + public function test_a_number_outside_its_bounds_or_off_its_grid_is_rejected_naming_what_it_missed( array $attributes, mixed $raw, string $code, string $message ): void { + $result = self::sanitize( $attributes, $raw ); + + self::assertInstanceOf( \WP_Error::class, $result ); + self::assertSame( $code, $result->get_error_code() ); + self::assertSame( $message, $result->get_error_message() ); + } + + protected static function sanitize( array $attributes, mixed $raw ): mixed { + $field = array( + 'type' => 'number', + 'id' => 'key', + 'custom_attributes' => $attributes, + ); + $result = Forms::sanitize_fields( array( $field ), array( 'key' => $raw ) ); + + return $result['errors']['key'] ?? $result['values']['key']; + } +} diff --git a/tests/Integration/Support/RecordedDefinition.php b/tests/Integration/Support/RecordedDefinition.php index e1726d5..28019f3 100644 --- a/tests/Integration/Support/RecordedDefinition.php +++ b/tests/Integration/Support/RecordedDefinition.php @@ -20,6 +20,11 @@ public function get_options_page( string $page_id ): array { return $this->record( $page_id ); } + #[\Override] + public function get_tab_fields( string $page_id, string $tab_slug ): array { + return $this->record( $page_id, $tab_slug ); + } + #[\Override] public function get_object_form( string $form_id, int $object_id ): array { return $this->record( $form_id, $object_id ); diff --git a/tests/Integration/Support/consumer-request.php b/tests/Integration/Support/consumer-request.php index c2937d8..8234e22 100644 --- a/tests/Integration/Support/consumer-request.php +++ b/tests/Integration/Support/consumer-request.php @@ -131,6 +131,7 @@ static function () use ( $spec, $acme_capability, $acme_record_boots, $acme_safe 'safeguard_attached' => $acme_safeguard_priority(), 'export_hooked' => has_action( 'admin_post_acme_ca_export' ), 'reports_page_class' => class_exists( 'Acme\ConsumerA\Reports\ReportsPage', false ), + 'reports_enabled' => function_exists( 'acme_ca_is_reports_enabled' ) ? acme_ca_is_reports_enabled() : null, 'updater_hooked' => has_filter( 'update_plugins_github.com' ), 'schedules_hooked' => has_filter( 'cron_schedules' ), 'safeguards_loaded' => class_exists( 'Acme\\ConsumerA\\Safeguards', false ), diff --git a/tests/Integration/WooCommerce/Setting/SettingsTest.php b/tests/Integration/WooCommerce/Setting/SettingsTest.php index 45033b5..d606d21 100644 --- a/tests/Integration/WooCommerce/Setting/SettingsTest.php +++ b/tests/Integration/WooCommerce/Setting/SettingsTest.php @@ -244,6 +244,46 @@ public function test_a_field_without_validate_stores_woocommerces_value(): void self::assertSame( 'bold', get_option( "{$this->prefix}_name" ) ); } + public static function bounded_numbers(): array { + return array( + 'whole-number-as-integer-text' => array( '4.0', '4', array() ), + 'off-its-step' => array( '2.5', '3', array( 'Count was not saved and keeps its previous value. The other changes were saved. Enter a number in steps of 1 from 1.' ) ), + 'below-its-min' => array( '0', '3', array( 'Count was not saved and keeps its previous value. The other changes were saved. Enter a number of at least 1.' ) ), + ); + } + + #[DataProvider( 'bounded_numbers' )] + public function test_a_number_field_without_validate_is_held_to_its_bounds_and_step( string $posted, string $stored, array $messages ): void { + update_option( "{$this->prefix}_count", '3' ); + $spec = $this->tab_spec( array( '' => self::section( 'General', array( $this->bounded_count() ) ) ) ); + + $saved = self::document( $this->save( $spec, '', array( "{$this->prefix}_count" => $posted ) )['body'] ); + + self::assertSame( $stored, get_option( "{$this->prefix}_count" ) ); + self::assertSame( $messages, self::texts( $saved, '#message.error' ) ); + } + + public function test_over_rest_a_number_field_without_validate_is_held_to_its_bounds_and_step(): void { + update_option( "{$this->prefix}_count", '3' ); + $this->add_tab_in_process( array( '' => $this->in_process_section( array( $this->bounded_count() ) ) ) ); + + $this->rest( 'PUT', "/wc/v3/settings/$this->prefix/{$this->prefix}_count", array( 'value' => '2.5' ) ); + $off_step = get_option( "{$this->prefix}_count" ); + $this->rest( 'PUT', "/wc/v3/settings/$this->prefix/{$this->prefix}_count", array( 'value' => 5.0 ) ); + + self::assertSame( '3', $off_step ); + self::assertSame( '5', get_option( "{$this->prefix}_count" ) ); + } + + public function test_a_number_field_woocommerce_draws_takes_any_step_unless_it_sets_one(): void { + $spec = $this->tab_spec( array( '' => self::section( 'General', array( $this->field( 'number', 'free' ), $this->bounded_count() ) ) ) ); + + $form = self::document( $this->open( $spec )['body'] ); + + self::assertSame( 'any', $form->getElementById( "{$this->prefix}_free" )?->getAttribute( 'step' ) ); + self::assertSame( '1', $form->getElementById( "{$this->prefix}_count" )?->getAttribute( 'step' ) ); + } + public function test_validate_receives_the_raw_submission_alone_and_its_return_value_is_stored(): void { $spec = $this->tab_spec( array( '' => self::section( 'General', array( $this->field( 'text', 'name', array( 'validate' => 'acme:uppercase' ) ) ) ) ) ); @@ -573,6 +613,19 @@ protected function field( string $type, string $name, array $keys = array() ): a ); } + protected function bounded_count(): array { + return $this->field( + 'number', + 'count', + array( + 'custom_attributes' => array( + 'min' => 1, + 'step' => 1, + ), + ) + ); + } + protected function custom_field( array $keys = array() ): array { return $this->field( "{$this->prefix}_widget", 'widget', $keys + array( 'render' => 'acme:render' ) ); }