diff --git a/AGENTS.md b/AGENTS.md index 5b5af73..607fbe6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,11 +11,11 @@ functions.php the @api dws_tpl_plugin(), the includes/ loader, th uninstall.php the framework's uninstall engine over Plugin::FOOTPRINT, with Options::DELETE_DATA's consent src/ the plugin's classes, each named for its concept ├── Plugin.php SLUG, PREFIX (dws_tpl), COMPONENTS, FOOTPRINT, the identity, the logger, the scheduler, the migration runner and the kernel -├── Options.php the option keys, their defaults and the typed readers +├── Options.php the option keys, their defaults in stored form and the typed readers ├── Migrations.php get_steps() over the protected steps, append-only -├── Settings.php the WooCommerce settings tab dws_tpl: the greeting's fields, their validator and the uninstall consent +├── Settings.php the WooCommerce settings tab dws_tpl: its registration, the uninstall consent and each feature's fields ├── Blocks.php the blocks from blocks/build/blocks-manifest.php and the block-editor script -└── Greeting/ the greeting: GreetingFeature, gated on Options::GREETING_ENABLED, and its AccountDashboardGreeting child, which owns the default message +└── Greeting/ the greeting: GreetingFeature, gated on Options::GREETING_ENABLED, its AccountDashboardGreeting child, which owns the default message, and GreetingSettings, its fields includes/ more @api functions and constants, grouped by topic, loaded in name order, _-prefixed files skipped ├── greeting.php whether the greeting is on, for an integration that needs the setting └── metadata.php the slug, name and version getters, the plugin's surface for a peer plugin @@ -56,9 +56,9 @@ Each runs PHP 8.5 with WooCommerce from `vendor/` and its order tables on; CI ru 3. **Request context.** `is_admin()` is false on REST requests and true in admin-ajax ones, so an admin-only feature decides the request context in its callbacks, never in `should_load()`. 4. **Add-ons.** An add-on extends a base plugin only through the base plugin's documented hooks. It reads a template-built base plugin's slug, name and version only through that plugin's `{prefix}_get_plugin_*()` getters, and a setting through an `@api` function where the base plugin offers one, else through the base plugin's options. It calls the base plugin's functions from `plugins_loaded` on, since the add-on may load first, and behind `function_exists()`, since a base plugin below its floors or with a partial build may stay active without defining them. 5. **Request reads.** An integer or boolean is read as `filter_var( wp_unslash( $_POST['key'] ?? '' ), FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE ) ?? $default` (`FILTER_VALIDATE_BOOL` for a boolean, where an absent key reads false, as an unchecked checkbox posts nothing), and a string through an `is_string()` guard and the field's sanitizer; never `filter_input()`, the `default` filter option or a cast on request input. -6. **Options and data.** Options are read only through `Options`' typed readers, which translate nothing, safeguards excepted; a translated default belongs to the component that shows it, as the greeting's message does. Everything stored lives under `dws_tpl_`, or `_dws_tpl_` for hidden meta, but for scheduled jobs, whose hooks take the hook root as every hook does and whose Action Scheduler group is the bare `dws_tpl`. The footprint covers the prefix's options, transients and user meta; object meta needs a `meta_prefixes` entry per type, and anything else, such as a scheduled hook, its own entry. +6. **Options and data.** Each setting is one autoloaded option, its key on `Options` and its default in stored form in `DEFAULTS`, which the first migration seeds, a release that adds settings seeds again, and its field reads; a feature's fields live in its folder's fields class. Outside the safeguards and the migration steps, options are read only through `Options`' typed readers, which translate nothing; a translated default belongs to the component that shows it, as the greeting's message does. Everything stored lives under `dws_tpl_`, or `_dws_tpl_` for hidden meta, but for scheduled jobs, whose hooks take the hook root as every hook does and whose Action Scheduler group is the bare `dws_tpl`. The footprint covers the prefix's options, transients and user meta; object meta needs a `meta_prefixes` entry per type, and anything else, such as a scheduled hook, its own entry. 7. **Safeguards** live in `Safeguards.php`, a final class whose `register()` hooks each safeguard as a static closure, which no code can remove by name, so a safeguard relaxes only through its own filter, through the protected `is_relaxed()` (`docs/recipes.md`); only what other classes read is public. The entry requires the class by path before the floor check, so it parses on PHP 7.4 and call only what their hook hands them, WordPress APIs present at the oldest WordPress the bootstrap package runs on and WooCommerce APIs present since 3.6, guarding anything newer with `function_exists()` or `method_exists()`. WordPress loads the plugin while WooCommerce is inactive, so a safeguard that calls WooCommerce is hooked from one closure, which `register()` runs on `woocommerce_loaded`, or at once where that has fired already, as on the request that activates the plugin; WooCommerce fires it, since 3.6, once every plugin has loaded. `phpstan.safeguards.neon` reads the class and the entry, with WooCommerce's stubs loaded, and checks the WordPress floor; review checks the WooCommerce floor, which no tool reads. -8. **Migrations** are append-only: a step converges from any partial state, verifies its writes and throws when one fails. +8. **Migrations** are append-only: a step converges from any partial state, verifies its writes and throws when one fails. A step names each stored name it touches, such as an option or meta key or a scheduled hook, and each default as a literal and reads stored rows directly, never through `Options` or a constant another class declares, so a later rename leaves what it does as it shipped; the seeding step alone reads `Options::DEFAULTS`. 9. **WordPress floor.** The plugin calls only APIs its `Requires at least` has, PHP's and JavaScript's alike: PHPStan's wp-compat reads the floor from that header, and CI runs the end-to-end suite on the floor's WordPress, which supplies the `@wordpress/*` scripts at runtime. ## Contract diff --git a/README.scaffold.md b/README.scaffold.md index a99cc3b..f8e857e 100644 --- a/README.scaffold.md +++ b/README.scaffold.md @@ -55,7 +55,7 @@ The fill-in renamed the plugin and kept the template's demo, which boots and pas | Part | Where it lives | Removing it | | --- | --- | --- | -| The greeting | `src/Greeting/`, the Greeting section of `src/Settings.php` with its validator, the `GREETING_ENABLED`, `GREETING` and `GREETING_ORDERS` keys in `src/Options.php`, and `includes/greeting.php`, the function through which another plugin reads whether it is on | Delete `src/Greeting/` and its row in `Plugin::COMPONENTS`, `includes/greeting.php`, the Greeting section of `src/Settings.php` with `validate_greeting_orders()`, `assets/src/greeting.scss` and its `greeting=` entry in `package.json`'s `build:assets` and `start:assets`, the three keys with their defaults, their lines in `Migrations::add_settings()`, and `tests/Integration/Greeting/`, then the rows naming them in `OptionsTest`, `MigrationsTest` and `SettingsTest`, and `PluginBootTest`'s expectation that the greeting is skipped. `tests/EndToEnd/settings.spec.ts` drives the greeting's validated field, so point it at a validated field of the plugin's own. | +| The greeting | `src/Greeting/`, its fields in `GreetingSettings`, which `src/Settings.php` spreads into the tab, the `GREETING_ENABLED`, `GREETING` and `GREETING_ORDERS` keys in `src/Options.php`, and `includes/greeting.php`, the function through which another plugin reads whether it is on | Delete `src/Greeting/` and its row in `Plugin::COMPONENTS`, `includes/greeting.php`, the `GreetingSettings` spread in `src/Settings.php`, `assets/src/greeting.scss` and its `greeting=` entry in `package.json`'s `build:assets` and `start:assets`, the three keys with their defaults, and `tests/Integration/Greeting/`, then the rows naming them in `OptionsTest`, the Integration `MigrationsTest` and `SettingsTest`, `PluginBootTest`'s expectation that the greeting is skipped, and the fingerprint in the Unit `MigrationsTest`, which becomes the one its failure prints, since no release has seeded the old keys. `tests/EndToEnd/settings.spec.ts` drives the greeting's number field, so point it at a field of the plugin's own. | | The example block | `blocks/src/example-notice/`, registered by `src/Blocks.php` from `blocks/build/blocks-manifest.php` and rendered by its `render.php` | Delete `blocks/`, `build:blocks` and `start:blocks` with their calls in `build` and `start`, `register_blocks()` and its `init` hook in `src/Blocks.php`, `BlocksTest`'s block rows, and the editor script's command, which inserts the block, with `tests/EndToEnd/editor.spec.ts`. | | The editor script | `assets/src/editor.ts`, a command in the editor's command palette that inserts the example block, enqueued in the block editor by `src/Blocks.php` | Delete it, its `editor=` entry in `build:assets` and `start:assets`, `@wordpress/commands` and `@wordpress/data` from `package.json`'s `devDependencies`, `enqueue_editor_script()` and its hook in `src/Blocks.php`, `BlocksTest`'s editor row and `tests/EndToEnd/editor.spec.ts`. Once `assets/src/` holds nothing, delete `build:assets` and `start:assets` with their calls in `build` and `start`. Once the plugin registers no blocks either, delete `src/Blocks.php`, its row in `Plugin::COMPONENTS` and `BlocksTest`. | | `models/` and `templates/` | Empty extension points, for classes on Composer's classmap and for template partials | Delete the folders the plugin never fills, and `models/` from `composer.json`'s `autoload.classmap` and `phpstan.dist.neon`'s `paths`, since Composer and PHPStan both fail on a missing folder. | diff --git a/composer.lock b/composer.lock index c2b4326..3d55cd7 100644 --- a/composer.lock +++ b/composer.lock @@ -189,12 +189,12 @@ "source": { "type": "git", "url": "https://github.com/ahegyes/wp-framework-core.git", - "reference": "6f191fb51f2887ce704a2c0248d9881839bea6e5" + "reference": "16a409b9f3503ac21dbd7225916d27cd4bf6cc5f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ahegyes/wp-framework-core/zipball/6f191fb51f2887ce704a2c0248d9881839bea6e5", - "reference": "6f191fb51f2887ce704a2c0248d9881839bea6e5", + "url": "https://api.github.com/repos/ahegyes/wp-framework-core/zipball/16a409b9f3503ac21dbd7225916d27cd4bf6cc5f", + "reference": "16a409b9f3503ac21dbd7225916d27cd4bf6cc5f", "shasum": "" }, "require": { @@ -229,7 +229,7 @@ "support": { "source": "https://github.com/ahegyes/wp-framework-core/tree/trunk" }, - "time": "2026-11-12T22:21:17+00:00" + "time": "2026-11-14T10:07:01+00:00" }, { "name": "ahegyes/wp-framework-settings", @@ -237,12 +237,12 @@ "source": { "type": "git", "url": "https://github.com/ahegyes/wp-framework-settings.git", - "reference": "1aff9aa8c65550bb744950ed7bcd2362f37ffae0" + "reference": "bcdb7184554d6c7d8a2f2a0f4a8362f5303308ab" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ahegyes/wp-framework-settings/zipball/1aff9aa8c65550bb744950ed7bcd2362f37ffae0", - "reference": "1aff9aa8c65550bb744950ed7bcd2362f37ffae0", + "url": "https://api.github.com/repos/ahegyes/wp-framework-settings/zipball/bcdb7184554d6c7d8a2f2a0f4a8362f5303308ab", + "reference": "bcdb7184554d6c7d8a2f2a0f4a8362f5303308ab", "shasum": "" }, "require": { @@ -271,7 +271,7 @@ "support": { "source": "https://github.com/ahegyes/wp-framework-settings/tree/trunk" }, - "time": "2026-11-12T20:06:51+00:00" + "time": "2026-11-14T11:10:00+00:00" }, { "name": "ahegyes/wp-framework-shared", @@ -318,12 +318,12 @@ "source": { "type": "git", "url": "https://github.com/ahegyes/wp-framework-woocommerce.git", - "reference": "01df7fb0739cdee77b356d7af6b085a8da5711bb" + "reference": "d01e17bb381a9d2e25d621901246382b2e0868af" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ahegyes/wp-framework-woocommerce/zipball/01df7fb0739cdee77b356d7af6b085a8da5711bb", - "reference": "01df7fb0739cdee77b356d7af6b085a8da5711bb", + "url": "https://api.github.com/repos/ahegyes/wp-framework-woocommerce/zipball/d01e17bb381a9d2e25d621901246382b2e0868af", + "reference": "d01e17bb381a9d2e25d621901246382b2e0868af", "shasum": "" }, "require": { @@ -359,7 +359,7 @@ "support": { "source": "https://github.com/ahegyes/wp-framework-woocommerce/tree/trunk" }, - "time": "2026-11-12T20:06:51+00:00" + "time": "2026-11-14T11:10:00+00:00" }, { "name": "automattic/jetpack-changelogger", diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 48afdb0..13cf77b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -27,14 +27,14 @@ The request that activates the plugin does not boot it, because WordPress includ ## Where code lives -- `src/` holds the plugin's classes, each final and named for its concept: the composition root; the components, which implement the framework's `ComponentInterface` and add their hooks in `register_hooks()`; and classes that register no hooks, `Options` and `Migrations`. A component takes the plugin's identity and ungated services in its constructor, such as the logger, the scheduler, `AdminNotices` or `Assets`, which `Plugin::get_definitions()` provides; a component that builds a settings page, product data tab or form passes the identity to it. +- `src/` holds the plugin's classes, each final and named for its concept: the composition root; the components, which implement the framework's `ComponentInterface` and add their hooks in `register_hooks()`; and classes that register no hooks, `Options`, `Migrations` and each feature's settings fields, such as `Greeting\GreetingSettings`, which `Settings` spreads into its tab. A component takes the plugin's identity and ungated services in its constructor, such as the logger, the scheduler, `AdminNotices` or `Assets`, which `Plugin::get_definitions()` provides; a component that builds a settings page, product data tab or form passes the identity to it. - `includes/` extends `functions.php` with more of the plugin's `@api` functions and constants, grouped by topic: the slug, name and version getters a peer plugin reads (`metadata.php`), and whether the greeting is on (`greeting.php`). The global namespace holds nothing else, so the plugin supports only what it tags as API. - `blocks/src/` and `assets/src/` hold the TypeScript and SCSS sources, which `npm run build` compiles into `blocks/build/` and `assets/build/`, and each block's `render.php`, which it copies beside the block's build and WordPress runs on each request, so the block's text follows the visitor's language; the framework's `Assets` reads the `*.asset.php` file wp-scripts writes beside each build, and `src/Blocks.php` registers the blocks from `blocks/build/blocks-manifest.php`. - `models/` and `templates/` are empty extension points, for classes on Composer's classmap and for template partials. ## Data -- **Options** are `Options`' constants, read only through its typed readers, which guard each stored value, fall back to the key's default and translate nothing. A translated default belongs to the component that shows it, after `init`: the greeting's field shows its default message as the placeholder and stores an empty value, which the greeting reads as that message in each visitor's language, so no save stores the admin's language. +- **Options** are `Options`' constants, one autoloaded option each, whose defaults `DEFAULTS` holds in the form each option stores. The first migration stores every absent default, a release that adds settings lists that step again, and each field's default reads the same entry. The components read them through `Options`' typed readers, which guard each stored value, fall back to the key's default and translate nothing. A translated default belongs to the component that shows it, after `init`: the greeting's field shows its default message as the placeholder and stores an empty value, which the greeting reads as that message in each visitor's language, so no save stores the admin's language. - **Migrations** are the append-only steps `Migrations::get_steps()` returns, each a protected static of the class. The framework's runner runs each pending step once per site on `wp_loaded`, behind a lock, and records the revision reached in `dws_tpl_revision`. A step converges from any partial state and throws when a write fails, so a failed step runs again on a later request. - **The footprint**, `Plugin::FOOTPRINT`, names every kind of data the plugin stores: option and user meta prefixes, network options, scheduled hooks and Action Scheduler groups. Options, transients and user meta under the `dws_tpl_` prefix are covered; meta on posts, terms, comments or orders needs a `meta_prefixes` entry per object type, and data under another name its own entry, which no check enforces beyond the option constants `FootprintTest` covers. - **Deactivation** unschedules every hook in the footprint's `cron_hooks` and `as_hooks` on the current site. diff --git a/docs/recipes.md b/docs/recipes.md index eca82ae..cf1b2e5 100644 --- a/docs/recipes.md +++ b/docs/recipes.md @@ -4,6 +4,9 @@ These are the changes most plugins built from this template make, each as the fi - [A safeguard](#a-safeguard) - [A one-time notice](#a-one-time-notice) +- [A setting](#a-setting) +- [Where stored data lives](#where-stored-data-lives) +- [Renaming a stored setting](#renaming-a-stored-setting) - [A WordPress settings page](#a-wordpress-settings-page) - [A product data tab](#a-product-data-tab) - [A form on an edit screen](#a-form-on-an-edit-screen) @@ -12,7 +15,9 @@ These are the changes most plugins built from this template make, each as the fi - [Rewrite rules after activation](#rewrite-rules-after-activation) - [A script module](#a-script-module) - [A setting another plugin reads](#a-setting-another-plugin-reads) +- [A filter at a decision point](#a-filter-at-a-decision-point) - [Renaming a public hook](#renaming-a-public-hook) +- [Retiring an @api function or constant](#retiring-an-api-function-or-constant) - [Distribution through wordpress.org](#distribution-through-wordpressorg) - [Without WooCommerce](#without-woocommerce) @@ -237,66 +242,139 @@ use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\Core\Admin The notice's scope defaults to the current user and its message is HTML, so escape it. A notice for every administrator of the site takes `NoticeScope::Site`, a capability and `per_viewer: true`, since without it the first administrator's viewing consumes it for everyone; one that stays until dismissed takes `persistent: true`. -## A WordPress settings page +## A setting -An options page adds a screen under a WordPress admin menu and stores each tab's fields in one option through the Settings API. WooCommerce settings belong on a WooCommerce tab, as `src/Settings.php` does. +A setting holds one value per site, which only a settings screen saves. Whatever the number of settings, each is one autoloaded option, declared once on `Options`, shown by a field and stored by the migrations. -Add the option, its field, its default and a reader that guards the field to `src/Options.php`. The non-WooCommerce recipe below uses the same names, with the uninstall consent as a second field: +1. In `src/Options.php`, add the key beside its feature's other keys, with a docblock, and its default to `DEFAULTS` in the form the option stores it: `'yes'` or `'no'` for a checkbox, digits for a number, a list for a multiselect, and `''` for text whose default is translated, which the component that shows the setting supplies after `init`, as the greeting's message does: ```php /** - * The option that holds the settings page's fields, keyed by field id. + * The option that holds the number of days after a customer's last paid order during which the greeting shows. * - * @since 1.0.0 - * @version 1.0.0 + * @since 1.1.0 + * @version 1.1.0 * * @var string */ - public const string SETTINGS = 'dws_tpl_settings'; + public const string GREETING_DAYS = 'dws_tpl_greeting_days'; + + // ... + + public const array DEFAULTS = array( + // ... + self::GREETING_DAYS => '30', + ); +``` + +2. Add the field to its feature's fields class, here `src/Greeting/GreetingSettings.php`. Its `default` reads `DEFAULTS`, and a number field states its bounds in `custom_attributes`, with a `min` beside any `step`, since the browser and the save both count the steps from it: + +```php + array( + 'type' => 'number', + 'id' => Options::GREETING_DAYS, + 'title' => __( 'Days since the last order', 'dws-plugin-template' ), + 'desc' => __( 'Customers see the greeting for this many days after their last paid order.', 'dws-plugin-template' ), + 'default' => Options::DEFAULTS[ Options::GREETING_DAYS ], + 'placeholder' => Options::DEFAULTS[ Options::GREETING_DAYS ], + 'custom_attributes' => array( + 'min' => '1', + 'step' => '1', + ), + ), +``` + +- The framework holds a number field to its bounds and stores an emptied one as empty, which the reader takes as the default the placeholder shows. +- A field that needs a rule the framework does not apply takes a `validate` callable, a protected static of the fields class handed over as `self::validate_…( ... )`. +- `Settings` keeps the WooCommerce tab's plugin-level fields, such as the consent to delete data, and spreads in the fields each feature's fields class returns. A fields class is final, with a protected constructor and a public static `get_settings_fields()`, as `GreetingSettings` is. +- A feature gated on another plugin spreads its fields only while that plugin is active; its keys and defaults stay on `Options`, so its settings are still stored and uninstalled. + +3. Read the setting through `Options`' typed readers, such as `Options::get_int( Options::GREETING_DAYS )`, which return the default while nothing valid is stored. + +4. In the release that adds the setting, append `self::add_settings( ... )` to `Migrations::get_steps()`, after the release's rename steps, so that existing sites store the new default. The Unit `MigrationsTest` then names the fingerprint to append to its `SEEDED_KEY_SETS`. Until the plugin's first release, the first step stores the default already, and the new fingerprint replaces the last one. +## Where stored data lives + +Each value the plugin stores is one of these kinds, the first that fits, and each kind has one home: + +- **A setting** holds one value per site, which the plugin declares and only a settings save writes: one autoloaded option, its key on `Options` and its default in `DEFAULTS`, as [A setting](#a-setting) adds. A setting never stores code or the name of a function to run, which would let anyone who saves the settings make the plugin run any function; a choice that leaves a decision to code is decided by a filter, as [A filter at a decision point](#a-filter-at-a-decision-point) shows. +- **A per-object override** is a setting's value for one product, order, user or term: one meta key, a constant on the class that stores it, as the product data tab and edit-screen recipes show. +- **A list setting** is a list edited on the settings screen whose entries pass none of the entity tests below: one option holding the list, as the Payments-tab recipe's multiselect stores its chosen keys. +- **An entity** is a record whose id another record stores, one written outside a settings save, or one that needs its own capability or REST route: a post type with meta, or a table of its own, which the footprint's `post_types`, `meta` and `tables` kinds remove. +- **Runtime state** is a value the plugin's code writes outside a form save, such as a sequence counter: an option of its own, written through core's `OptionRows`, whose conditional writes keep two requests from losing an update, never autoloaded and never in `DEFAULTS`. Its key is a constant on the class that owns it, under the plugin's prefix, and `FootprintTest`, which reads only `Options`' keys, gains a row for it: + +```php + public function test_it_covers_the_request_counter(): void { + self::assertTrue( \array_any( Plugin::FOOTPRINT['option_prefixes'], static fn ( string $prefix ): bool => \str_starts_with( RequestNumbers::COUNTER, $prefix ) ) ); + } +``` + +- **A marker that only spares repeated work for a while** is a transient with an expiry, its name a constant on the class that sets it, which the footprint's `_transient_` prefixes cover, as the recurring-jobs recipe's repair gate is. + +No setting is written through `OptionRows`, whose writes take a row out of autoload. + +## Renaming a stored setting + +A stored option's name is part of the plugin's contract, since other plugins read it with `get_option()`, so a rename ships in a major release: + +1. Change the key's value in `src/Options.php`; its readers, its field and `DEFAULTS` follow. +2. Add a plain step to `src/Migrations.php` that copies the old row over the new one and deletes the old row. The old row wins, so a value the site stored outlives a default that a seeding step, or a settings save, stored under the new name before the step ran. Append it after the steps earlier releases shipped, which keep their places, and before the release's own batched steps and its `self::add_settings( ... )`, which the changed keys call for, as [A setting](#a-setting) says: + +```php /** - * The settings field that holds the limit. + * Moves the stored record limit from its 1.x name to its 2.0 name, letting the old row win. * - * @since 1.0.0 - * @version 1.0.0 + * @since 2.0.0 + * @version 2.0.0 * - * @var string + * @throws \RuntimeException Thrown when the limit could not be moved. + * + * @return void */ - public const string SETTING_LIMIT = 'limit'; + protected static function rename_record_limit(): void { + $stored = get_option( 'dws_tpl_max_records' ); + if ( false === $stored ) { + return; + } + + if ( get_option( 'dws_tpl_record_limit' ) !== $stored && ! update_option( 'dws_tpl_record_limit', $stored, true ) ) { + throw new \RuntimeException( 'The option dws_tpl_record_limit could not be stored.' ); + } + if ( ! delete_option( 'dws_tpl_max_records' ) ) { + throw new \RuntimeException( 'The option dws_tpl_max_records could not be deleted.' ); + } + } +``` + +3. Keep the name of a setting that hides or restricts something, whose default is less restrictive than a value a site may have stored. Gates read settings on `plugins_loaded`, before the migrations run on `wp_loaded`, so on the first request after the update the new name would read its default and show what the site chose to hide. +4. Add a changelog fragment with `composer changelog:add` that names the old and the new option, with a major significance. + +## A WordPress settings page + +An options page adds a screen under a WordPress admin menu and stores each field in its own option through the Settings API, as WooCommerce's settings tabs do, so a setting stores and reads alike on either screen. WooCommerce settings belong on a WooCommerce tab, as `src/Settings.php` does. +Add the setting to `src/Options.php` as [A setting](#a-setting) does. The non-WooCommerce recipe below uses the same names: + +```php /** - * The value each settings field reads as while nothing valid is stored. + * The option that holds the most records the plugin keeps. * * @since 1.0.0 * @version 1.0.0 * - * @var array + * @var string */ - public const array SETTING_DEFAULTS = array( - self::SETTING_LIMIT => 1, - ); + public const string LIMIT = 'dws_tpl_limit'; // ... - /** - * Returns a settings field as an integer. - * - * @since 1.0.0 - * @version 1.0.0 - * - * @param string $field The field's id. - * - * @return int - */ - public static function get_int_setting( string $field ): int { - $default = self::SETTING_DEFAULTS[ $field ] ?? null; - $stored = get_option( self::SETTINGS ); - $stored = \is_array( $stored ) && \array_key_exists( $field, $stored ) ? $stored[ $field ] : $default; - return \filter_var( $stored, \FILTER_VALIDATE_INT, \FILTER_NULL_ON_FAILURE ) ?? ( \is_int( $default ) ? $default : 0 ); - } + public const array DEFAULTS = array( + // ... + self::LIMIT => '1', + ); ``` -Add the page as a component, `src/SettingsPage.php`, with its validator, and list it in `Plugin::COMPONENTS`. The page takes the plugin's identity, which the container provides: +Add the page as a component, `src/SettingsPage.php`, and list it in `Plugin::COMPONENTS`. The page takes the plugin's identity, which the container provides. Its definition names the page and its tabs and gives each tab's fields apart, so the page asks only for the fields of the tab it shows or saves: ```php __( 'DWS Plugin Template', 'dws-plugin-template' ), 'tabs' => array( array( - 'slug' => 'general', - 'title' => __( 'General', 'dws-plugin-template' ), - 'option' => Options::SETTINGS, - 'fields' => array( - array( - 'type' => 'number', - 'id' => Options::SETTING_LIMIT, - 'title' => __( 'Limit', 'dws-plugin-template' ), - 'default' => (string) Options::SETTING_DEFAULTS[ Options::SETTING_LIMIT ], - 'custom_attributes' => array( - 'min' => '1', - 'step' => '1', - ), - 'validate' => self::validate_limit( ... ), - ), - ), + 'slug' => 'general', + 'title' => __( 'General', 'dws-plugin-template' ), ), ), ); } - // endregion - - // region HELPERS - /** - * Returns the limit as the settings page stores it, or an error when the submission is not a whole number of at least 1. - * - * The number input sends a whole number in any form a browser accepts, such as 03 or 3.0, so the submission parses as a float. + * {@inheritDoc} * * @since 1.0.0 * @version 1.0.0 - * - * @param mixed $submitted The submitted value. - * - * @return int|\WP_Error */ - protected static function validate_limit( mixed $submitted ): int|\WP_Error { - $limit = \is_string( $submitted ) ? \filter_var( $submitted, \FILTER_VALIDATE_FLOAT ) : false; - if ( false === $limit || $limit < 1 || $limit >= \PHP_INT_MAX || \floor( $limit ) !== $limit ) { - return new \WP_Error( 'invalid_number', __( 'Enter a whole number of 1 or more, such as 3.', 'dws-plugin-template' ) ); - } - - return (int) $limit; + #[\Override] + public function get_tab_fields( string $page_id, string $tab_slug ): array { + return match ( $tab_slug ) { + 'general' => array( + array( + 'type' => 'number', + 'id' => Options::LIMIT, + 'title' => __( 'Limit', 'dws-plugin-template' ), + 'default' => Options::DEFAULTS[ Options::LIMIT ], + 'placeholder' => Options::DEFAULTS[ Options::LIMIT ], + 'custom_attributes' => array( + 'min' => '1', + 'step' => '1', + ), + ), + ), + default => array(), + }; } // endregion } ``` -Read the limit with `Options::get_int_setting( Options::SETTING_LIMIT )`, which returns the default until a valid limit is stored. The option is under the plugin's prefix, so the footprint covers it; a migration step that stores `Options::SETTING_DEFAULTS` autoloaded saves the first request's query, as the non-WooCommerce recipe's step does. +- Each tab saves as the settings group named by the page's id and the tab's slug, here `dws_tpl_general`. +- A field's id is the option it stores, which starts with `dws_tpl_`, since the page's capability writes it; the page leaves out any other field and reports it through `_doing_it_wrong()`. +- The framework holds the limit to its `min` and `step` and stores it as plain digits. A rejected value keeps the stored one, and its message shows among the page's notices. +- Another plugin adds tabs through `dws_plugin_template/options_page/tabs`, and fields through `dws_plugin_template/options_page/fields`, which passes each tab's slug; a tab another plugin adds gets none of the definition's fields, since `get_tab_fields()` returns none for a slug it does not name. + +Read the limit with `Options::get_int( Options::LIMIT )`, which returns the default until a valid limit is stored. ## A product data tab @@ -675,7 +745,7 @@ final class PaymentSettings implements ComponentInterface, SectionFieldsDefiniti 'id' => Options::PAYMENT_NOTES, 'title' => __( 'Payment notes', 'dws-plugin-template' ), 'desc' => __( 'Add a note to each order when its payment completes', 'dws-plugin-template' ), - 'default' => wc_bool_to_string( Options::DEFAULTS[ Options::PAYMENT_NOTES ] ), + 'default' => Options::DEFAULTS[ Options::PAYMENT_NOTES ], ), array( 'type' => 'multiselect', @@ -684,6 +754,7 @@ final class PaymentSettings implements ComponentInterface, SectionFieldsDefiniti 'desc' => __( 'Add the note only to orders paid with these methods', 'dws-plugin-template' ), 'class' => 'wc-enhanced-select', 'options' => $methods, + 'default' => Options::DEFAULTS[ Options::PAYMENT_METHODS ], ), array( 'type' => 'sectionend', @@ -738,7 +809,7 @@ final class PaymentSettings implements ComponentInterface, SectionFieldsDefiniti } ``` -WooCommerce stores each field in its own option under its id, flat rather than in a group: a checkbox stores `yes` or `no`, which `Options::get_bool()` reads, and a multiselect the list of the chosen keys, which a list reader reads. Add each key to `src/Options.php` with its default in `DEFAULTS`, `false` for the checkbox and an empty array for the multiselect, whose `@var` then allows `list`, and the list reader beside the other typed readers: +WooCommerce stores each field in its own option under its id, flat rather than in a group: a checkbox stores `yes` or `no`, which `Options::get_bool()` reads, and a multiselect the list of the chosen keys, which a list reader reads. Add each key to `src/Options.php` as [A setting](#a-setting) does, with its default in `DEFAULTS`, `'no'` for the checkbox and an empty array for the multiselect, whose `@var` then reads `array>`, and the list reader beside the other typed readers: ```php /** @@ -787,21 +858,7 @@ WooCommerce's save drops a multiselect key of `'0'` or `''`, so each key stays t A recurring job runs a hook at an interval through the plugin's scheduler, which uses Action Scheduler where it is loaded and WP-Cron otherwise. Either can run an invocation twice or skip one, so the job's callback leaves its data consistent after a duplicate or a missed run. -Add the repair gate's option to `src/Options.php`, with its default `0` in `DEFAULTS`: - -```php - /** - * The option that holds when the plugin last ensured its recurring jobs, which lets a request skip ensuring them for an hour. - * - * @since 1.0.0 - * @version 1.0.0 - * - * @var string - */ - public const string RECURRING_ENSURED_AT = 'dws_tpl_recurring_ensured_at'; -``` - -Add the job as a component, `src/Pruning.php`, and list it in `Plugin::COMPONENTS`. It ensures the schedule on `wp_loaded` at most once an hour, and again when Action Scheduler asks every day. Writing the time first narrows the window in which two requests both ensure it, and `ensure_recurring()` repairs a duplicate chain on its next pass: +Add the job as a component, `src/Pruning.php`, and list it in `Plugin::COMPONENTS`. It ensures the schedule on `wp_loaded` at most once an hour, behind a transient that lives an hour, and again when Action Scheduler asks every day. Setting the transient first narrows the window in which two requests both ensure it, and `ensure_recurring()` repairs a duplicate chain on its next pass: ```php ensure_schedule(); } @@ -932,7 +998,7 @@ final class Pruning implements ComponentInterface { Then: - Add `Pruning::HOOK` to `cron_hooks` in `Plugin::FOOTPRINT`. Deactivation unschedules every hook there through the plugin's scheduler, and uninstall clears WP-Cron's copies, while Action Scheduler's are in the footprint's `as_groups` already. -- Delete the repair gate's option on activation, so the next request ensures the jobs: register an activation closure in `functions.php` beside the deactivation hook, as the rewrite-rules recipe shows, that calls `delete_option( Options::RECURRING_ENSURED_AT )`. A plugin that also takes the rewrite-rules recipe keeps one activation closure that does both. +- Delete the repair gate's transient on activation, so the next request ensures the jobs: register an activation closure in `functions.php` beside the deactivation hook, as the rewrite-rules recipe shows, that calls `delete_transient( Pruning::ENSURED )`. The footprint's `_transient_` prefixes already cover the transient. A plugin that also takes the rewrite-rules recipe keeps one activation closure that does both. - Change an interval in code only: `ensure_recurring()` replaces a schedule whose interval differs on the next pass of the repair gate. A "run now" schedules the same hook with an argument that sets it apart, or calls `prune()` directly. ## Rewrite rules after activation @@ -1001,7 +1067,7 @@ Another plugin can always read the plugin's options through `get_option()`, sinc * @return int */ function dws_tpl_get_limit(): int { - return Options::get_int_setting( Options::SETTING_LIMIT ); + return Options::get_int( Options::LIMIT ); } ``` @@ -1011,6 +1077,32 @@ function dws_tpl_get_limit(): int { - Another plugin calls the function from `plugins_loaded` on and behind `function_exists()`, as AGENTS.md's add-on convention asks of every `@api` function, since the plugin may load later or stay active below its floors without defining it. - Tag the function `@api`, which `GlobalsTest` checks. It is contract from its first release, so a break of it ships as the next major, as AGENTS.md's Contract section says. +## A filter at a decision point + +Where other plugins should be able to change what the plugin decides, apply a filter at that decision and pass it the object the decision is for, so a listener changes only the cases it means. For a greeting that other code may hold back from some customers, `AccountDashboardGreeting::print_greeting()` decides through a filter once it has counted the paid orders: + +```php + $shown = \is_array( $paid ) && \count( $paid ) >= $threshold; + + /** + * Filters whether a customer sees the account dashboard greeting. + * + * @since 1.1.0 + * @version 1.1.0 + * + * @param bool $shown Whether the customer has enough paid orders to see the greeting. + * @param \WP_User $customer The customer. + */ + if ( true !== apply_filters( 'dws_plugin_template/greeting/should_show', $shown, wp_get_current_user() ) ) { + return; + } +``` + +- Name the filter by AGENTS.md's hook rules, a predicate for a yes-or-no decision, and keep the strict comparison at the call site, so a listener that returns anything but `true` leaves the greeting out. +- A choice that leaves the decision to code, such as "custom", is answered by such a filter, which a site fills with its own code; no setting stores code or the name of a function to run. +- Apply no filter to every read of a setting: it would fire for every setting with no context, while WordPress's `pre_option_{$option}` and `option_{$option}` filters already override one stored value where a site needs that. A plugin whose v1 filtered each settings read keeps that filter, with v1's field ids, through any change to how its settings are stored. +- The filter's name and arguments are contract from the release that adds it, as every hook the plugin fires. + ## Renaming a public hook A public hook's name is part of the plugin's contract, as its arguments, its options and its REST routes are. To rename one in a released plugin: @@ -1038,6 +1130,48 @@ $restricted = apply_filters( 'dws_plugin_template/data_export/should_restrict', 4. Migrate stored data that holds the name, such as a scheduled job's hook, in a migration step that converges from any partial state: unschedule the old name, then ensure the new one. 5. In the next major, delete the deprecated call, with a major fragment that names the old hook as removed. +## Retiring an @api function or constant + +An `@api` function or constant that a tagged release shipped is contract until the next major, so it keeps working until then; one that no release has shipped is deleted outright. To retire one in a released plugin: + +1. Keep it, delegating to its replacement where it has one, with a `@deprecated` tag that names the release and the replacement. A function raises WordPress's deprecation notice through `_deprecated_function()`, and a constant declared with `const` raises PHP's through the `#[\Deprecated]` attribute; a constant set with `define()` takes the tag alone, since nothing raises a notice for it: + +```php +/** + * Returns the most records the plugin keeps. + * + * @api + * + * @since 1.0.0 + * @version 1.1.0 + * @deprecated 1.1.0 Use dws_tpl_get_record_limit(). + * + * @return int + */ +function dws_tpl_get_limit(): int { + _deprecated_function( __FUNCTION__, '1.1.0', 'dws_tpl_get_record_limit()' ); + return dws_tpl_get_record_limit(); +} + +/** + * The most records the plugin keeps. + * + * @api + * + * @since 1.0.0 + * @version 1.1.0 + * @deprecated 1.1.0 Use DWS_TPL_RECORD_LIMIT. + * + * @var int + */ +#[\Deprecated( 'use DWS_TPL_RECORD_LIMIT', since: '1.1.0' )] +const DWS_TPL_LIMIT = 10; +``` + +2. Move the plugin's own calls to the replacement in the same release; PHPStan reports each use that remains. +3. Add a changelog fragment with `composer changelog:add` that names the old symbol and its replacement, with a minor significance and the Deprecated type. +4. In the next major, delete it, with a major fragment that names it as removed. + ## Distribution through wordpress.org A plugin listed on wordpress.org gets its updates and its language packs from there, so it drops the GitHub updater's header and adds the directory's `readme.txt`. @@ -1212,88 +1346,36 @@ use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\Core\Migra "start:assets": "wp-scripts start editor=./assets/src/editor.ts --output-path=assets/build" ``` -7. Delete `src/Settings.php`, and add the settings page from [A WordPress settings page](#a-wordpress-settings-page), with the consent to delete the plugin's data as a second field of its tab: +7. Delete `src/Settings.php`, and add the settings page from [A WordPress settings page](#a-wordpress-settings-page), with the consent to delete the plugin's data as a second field of its tab, under the option `uninstall.php` already reads: ```php - array( - 'type' => 'checkbox', - 'id' => Options::SETTING_DELETE_DATA, - 'title' => __( 'Remove all data on uninstallation', 'dws-plugin-template' ), - 'desc' => __( 'Delete the plugin\'s data when it is uninstalled, so it must be configured again if it is installed later', 'dws-plugin-template' ), - ), -``` - -8. In `src/Options.php`, delete the greeting's three keys with their defaults, and `DELETE_DATA`. The typed option readers stay for a standalone option, such as the recurring-jobs recipe's, with its default in `DEFAULTS`; while the plugin has none, delete them with `DEFAULTS` and `get_default()`, since PHPStan refuses a lookup in an empty array. Add the settings-page recipe's settings option, field, default and reader, then the consent field, its default `false` in `SETTING_DEFAULTS`, whose `@var` then reads `array`, and a reader for it: - -```php - /** - * The settings field 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 SETTING_DELETE_DATA = 'delete_data'; - - // ... - - /** - * Returns a settings field as a boolean, which reads checkbox values such as 'yes' and 'no'. - * - * @since 1.0.0 - * @version 1.0.0 - * - * @param string $field The field's id. - * - * @return bool - */ - public static function get_bool_setting( string $field ): bool { - $default = self::SETTING_DEFAULTS[ $field ] ?? null; - $stored = get_option( self::SETTINGS ); - $stored = \is_array( $stored ) && \array_key_exists( $field, $stored ) ? $stored[ $field ] : $default; - return \filter_var( $stored, \FILTER_VALIDATE_BOOL, \FILTER_NULL_ON_FAILURE ) ?? ( true === $default ); - } + 'general' => array( + // ... the limit ... + array( + 'type' => 'checkbox', + 'id' => Options::DELETE_DATA, + 'title' => __( 'Remove all data on uninstallation', 'dws-plugin-template' ), + 'desc' => __( 'Delete the plugin\'s data when it is uninstalled, so it must be configured again if it is installed later', 'dws-plugin-template' ), + 'default' => Options::DEFAULTS[ Options::DELETE_DATA ], + ), + ), ``` -9. In `src/Migrations.php`, the first step stores the defaults as they are. A stored `false` renders as an unchecked checkbox, the readers guard it, and uninstall consents only on `'yes'`: +8. In `src/Options.php`, delete the greeting's three keys with their defaults, and add the settings page's limit with its default. `src/Migrations.php` stays as it is, since its first step stores every default in `DEFAULTS`. In `tests/Unit/MigrationsTest.php`, the fingerprint in `SEEDED_KEY_SETS` becomes the one the suite's failure names, since no release has stored the old keys. -```php - /** - * Stores the settings' defaults, autoloaded, so a site that never saved the settings page reads them without a query. - * - * @since 1.0.0 - * @version 1.0.0 - * - * @throws \RuntimeException Thrown when the defaults could not be stored. - * - * @return void - */ - protected static function add_settings(): void { - if ( false === get_option( Options::SETTINGS ) && ! add_option( Options::SETTINGS, Options::SETTING_DEFAULTS, '', true ) ) { - throw new \RuntimeException( 'The option ' . Options::SETTINGS . ' could not be stored.' ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing, never rendered. - } - } -``` - -10. In `uninstall.php`, delete the `MetaTypes` import and `$dws_tpl_meta_types`, and take consent from the settings row's `delete_data` key, read inside the `try` as the footprint is: +9. In `uninstall.php`, delete the `MetaTypes` import and `$dws_tpl_meta_types`, so the engine runs without WooCommerce's meta types: ```php - $dws_tpl_footprint = Plugin::FOOTPRINT; - $dws_tpl_consent = array( - 'option' => Options::SETTINGS, - 'key' => Options::SETTING_DELETE_DATA, - ); -// ... - Uninstaller::run( $dws_tpl_identity, $dws_tpl_footprint, - array( 'consent' => $dws_tpl_consent ) + array( + 'consent' => array( 'option' => $dws_tpl_consent ), + ) ); ``` -11. In `.wp-env.json`, `.wp-env.tests.json` and `.wp-env.multisite.json`, delete the WooCommerce mapping, and let `afterStart` only activate the plugin, network-wide in the multisite one; `.wp-env.json`'s command takes no `--config`: +10. In `.wp-env.json`, `.wp-env.tests.json` and `.wp-env.multisite.json`, delete the WooCommerce mapping, and let `afterStart` only activate the plugin, network-wide in the multisite one; `.wp-env.json`'s command takes no `--config`: ```json "mappings": { @@ -1304,43 +1386,12 @@ use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\Core\Migra } ``` -12. Adapt the tests: +11. Adapt the tests: - Delete `tests/EndToEnd/without-woocommerce.spec.ts` and `tests/Support/OrderStorageTrait.php`. -- `OptionsTest` reads through `Options::get_bool_setting()` and `Options::get_int_setting()` from an `Options::SETTINGS` row, and a malformed limit reads its default 1; the option readers' rows go if step 8 deleted the readers. -- `MigrationsTest` expects `Options::SETTING_DEFAULTS` stored autoloaded, a stored row kept as it is, and the throw when that option's `INSERT` fails. -- `UninstallTest` consents through `array( Options::SETTING_DELETE_DATA => 'yes' )`, seeds and asserts no Action Scheduler action, and expects the defaults stored again afterwards. -- `PartialDeployTest`'s missing-engine row seeds and asserts consent through `array( Options::SETTING_DELETE_DATA => 'yes' )` in `Options::SETTINGS`, and restores the row it read. -- `MultisiteTest` reads the second site's consent from its `Options::SETTINGS` row. +- `OptionsTest` and the Integration `MigrationsTest` read and seed `Options::LIMIT` where they read the greeting's keys, and a malformed limit reads its default 1. +- `UninstallTest`, `PartialDeployTest` and `MultisiteTest` keep their consent rows, since the consent keeps its option; `UninstallTest` seeds and asserts no Action Scheduler action. - `PluginBootTest` expects nothing in `$plugin->kernel->skipped`, and drops the `DWS_TPL_DEBUG` row with its `get_threshold()` helper and the `WooCommerceLogger` and `RunInSeparateProcess` imports. -- `SettingsTest` builds `new OptionsPage( $identity, Plugin::PREFIX, new SettingsPage( $identity ) )` over `new PluginIdentity( DWS_TPL_FILE, Plugin::SLUG, Plugin::PREFIX )` and calls its `register_settings()` in `setUpBeforeClass()`, which first requires `wp-admin/includes/template.php`. `tearDownAfterClass()` removes the page's `pre_update_option_` and `option_page_capability_` filters and calls `unregister_setting()`. Each test resets `$GLOBALS['wp_settings_errors']`, renders through `render_page()` and saves as `options.php` does: - -```php - // options.php saves a submission through update_option(), whose filter the page hooks for its own option page only. - protected function save( array $posted ): void { - $GLOBALS['pagenow'] = 'options.php'; - $_REQUEST['option_page'] = Options::SETTINGS; - - try { - update_option( Options::SETTINGS, $posted ); - } finally { - unset( $GLOBALS['pagenow'], $_REQUEST['option_page'] ); - } - } -``` - -- `tests/EndToEnd/settings.spec.ts` drives the page's limit field with the same checks: it focuses `page.getByLabel( 'Limit' )`, tabs to the `Save Changes` button and scopes axe to the form: - -```ts -const SAVED = 'Settings saved.'; -const REJECTED = - 'Limit was not saved and keeps its previous value. The other changes were saved. Enter a whole number of 1 or more, such as 3.'; -const FORM = 'form[action="options.php"]'; - -// ... - - test.beforeEach( async ( { admin } ) => { - await admin.visitAdminPage( 'options-general.php', 'page=dws_tpl' ); - } ); -``` +- `SettingsTest` builds `new OptionsPage( $identity, Plugin::PREFIX, new SettingsPage( $identity ) )` over `new PluginIdentity( DWS_TPL_FILE, Plugin::SLUG, Plugin::PREFIX )` and renders it through `render_page()`, which first needs `wp-admin/includes/template.php`. It saves as `options.php` does: with `$GLOBALS['pagenow']` set to `options.php` and `$_REQUEST['option_page']` to `dws_tpl_general`, it calls `register_settings()`, then `update_option()` for each of the tab's options, with null for one the form did not post. `tearDownAfterClass()` removes each field's `pre_update_option_` filter and the group's `option_page_capability_` filter, and unregisters each setting. +- `tests/EndToEnd/settings.spec.ts` drives the page's limit field at `options-general.php?page=dws_tpl` with the same keyboard checks, scoping axe to `form[action="options.php"]`, and expects WordPress's "Settings saved." after each save. -13. Update `AGENTS.md`, `README.md` and `docs/ARCHITECTURE.md` wherever they name WooCommerce, the greeting, the settings tab or the order-tables declaration. +12. Update `AGENTS.md`, `README.md` and `docs/ARCHITECTURE.md` wherever they name WooCommerce, the greeting, the settings tab or the order-tables declaration. diff --git a/src/Greeting/GreetingSettings.php b/src/Greeting/GreetingSettings.php new file mode 100644 index 0000000..f7e52c8 --- /dev/null +++ b/src/Greeting/GreetingSettings.php @@ -0,0 +1,85 @@ + + */ + public static function get_settings_fields(): array { + return array( + array( + 'type' => 'title', + 'id' => Plugin::PREFIX . '_greeting_options', + 'title' => __( 'Greeting', 'dws-plugin-template' ), + 'desc' => __( 'Greets returning customers on their account dashboard.', 'dws-plugin-template' ), + ), + array( + 'type' => 'checkbox', + 'id' => Options::GREETING_ENABLED, + 'title' => __( 'Show the greeting', 'dws-plugin-template' ), + 'desc' => __( 'Greet returning customers', 'dws-plugin-template' ), + 'default' => Options::DEFAULTS[ Options::GREETING_ENABLED ], + ), + array( + 'type' => 'text', + 'id' => Options::GREETING, + 'title' => __( 'Message', 'dws-plugin-template' ), + 'desc' => __( 'Leave empty to show the default greeting in each customer\'s language.', 'dws-plugin-template' ), + 'default' => Options::DEFAULTS[ Options::GREETING ], + 'placeholder' => AccountDashboardGreeting::get_default_message(), // A default would be saved in the admin's language with the first save of the tab. + ), + array( + 'type' => 'number', + 'id' => Options::GREETING_ORDERS, + 'title' => __( 'Orders before the greeting', 'dws-plugin-template' ), + 'desc' => __( 'Customers see the greeting once this many of their orders are paid.', 'dws-plugin-template' ), + 'default' => Options::DEFAULTS[ Options::GREETING_ORDERS ], + 'placeholder' => Options::DEFAULTS[ Options::GREETING_ORDERS ], + 'custom_attributes' => array( + 'min' => '1', + 'step' => '1', + ), + ), + array( + 'type' => 'sectionend', + 'id' => Plugin::PREFIX . '_greeting_options', + ), + ); + } + + // endregion +} diff --git a/src/Migrations.php b/src/Migrations.php index c473e5a..4eef40f 100644 --- a/src/Migrations.php +++ b/src/Migrations.php @@ -44,9 +44,9 @@ public static function get_steps(): array { // region HELPERS /** - * Stores the defaults of the settings that need no translation. + * Stores each setting's default in an autoloaded row of its own and leaves a stored setting as it is. * - * A translated default is never stored, because it would keep the language the site had when the step ran. + * 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 @@ -56,13 +56,8 @@ public static function get_steps(): array { * @return void */ protected static function add_settings(): void { - $defaults = array( - Options::GREETING_ENABLED => wc_bool_to_string( Options::DEFAULTS[ Options::GREETING_ENABLED ] ), - Options::GREETING_ORDERS => (string) Options::DEFAULTS[ Options::GREETING_ORDERS ], - Options::DELETE_DATA => wc_bool_to_string( Options::DEFAULTS[ Options::DELETE_DATA ] ), - ); - - foreach ( $defaults as $key => $value ) { + 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/src/Options.php b/src/Options.php index 07e6c9f..9069b2e 100644 --- a/src/Options.php +++ b/src/Options.php @@ -54,17 +54,20 @@ final class Options { public const string GREETING_ORDERS = 'dws_tpl_greeting_orders'; /** - * The value each option reads as while nothing valid is stored. + * The default of each option, in the form the option stores it. + * + * An empty string stands in for a translated default, which the component that shows the option supplies. * * @since 1.0.0 * @version 1.0.0 * - * @var array + * @var array */ public const array DEFAULTS = array( - self::DELETE_DATA => false, - self::GREETING_ENABLED => false, - self::GREETING_ORDERS => 1, + self::DELETE_DATA => 'no', + self::GREETING => '', + self::GREETING_ENABLED => 'no', + self::GREETING_ORDERS => '1', ); // endregion @@ -95,7 +98,7 @@ protected function __construct() {} */ public static function get_bool( string $key ): bool { $default = self::get_default( $key ); - return \filter_var( get_option( $key, $default ), \FILTER_VALIDATE_BOOL, \FILTER_NULL_ON_FAILURE ) ?? ( true === $default ); + return \filter_var( get_option( $key, $default ), \FILTER_VALIDATE_BOOL, \FILTER_NULL_ON_FAILURE ) ?? \filter_var( $default, \FILTER_VALIDATE_BOOL ); } /** @@ -110,7 +113,7 @@ public static function get_bool( string $key ): bool { */ public static function get_int( string $key ): int { $default = self::get_default( $key ); - return \filter_var( get_option( $key, $default ), \FILTER_VALIDATE_INT, \FILTER_NULL_ON_FAILURE ) ?? ( \is_int( $default ) ? $default : 0 ); + return \filter_var( get_option( $key, $default ), \FILTER_VALIDATE_INT, \FILTER_NULL_ON_FAILURE ) ?? \filter_var( $default, \FILTER_VALIDATE_INT, \FILTER_NULL_ON_FAILURE ) ?? 0; } /** diff --git a/src/Settings.php b/src/Settings.php index 39526ff..406bfb2 100644 --- a/src/Settings.php +++ b/src/Settings.php @@ -2,7 +2,7 @@ namespace DeepWebSolutions\PluginTemplate; -use DeepWebSolutions\PluginTemplate\Greeting\AccountDashboardGreeting; +use DeepWebSolutions\PluginTemplate\Greeting\GreetingSettings; use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\Core\ComponentInterface; use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\WooCommerce\Setting\SectionFieldsDefinitionInterface; use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\WooCommerce\Setting\SettingsTab; @@ -38,43 +38,7 @@ public function register_hooks(): void { #[\Override] public function get_section_fields( string $tab_id, string $section_id ): array { return array( - array( - 'type' => 'title', - 'id' => Plugin::PREFIX . '_greeting_options', - 'title' => __( 'Greeting', 'dws-plugin-template' ), - 'desc' => __( 'Greets returning customers on their account dashboard.', 'dws-plugin-template' ), - ), - array( - 'type' => 'checkbox', - 'id' => Options::GREETING_ENABLED, - 'title' => __( 'Show the greeting', 'dws-plugin-template' ), - 'desc' => __( 'Greet returning customers', 'dws-plugin-template' ), - 'default' => wc_bool_to_string( Options::DEFAULTS[ Options::GREETING_ENABLED ] ), - ), - array( - 'type' => 'text', - 'id' => Options::GREETING, - 'title' => __( 'Message', 'dws-plugin-template' ), - 'desc' => __( 'Leave empty to show the default greeting in each customer\'s language.', 'dws-plugin-template' ), - 'default' => '', - 'placeholder' => AccountDashboardGreeting::get_default_message(), // A default would be saved in the admin's language with the first save of the tab. - ), - array( - 'type' => 'number', - 'id' => Options::GREETING_ORDERS, - 'title' => __( 'Orders before the greeting', 'dws-plugin-template' ), - 'desc' => __( 'Customers see the greeting once this many of their orders are paid.', 'dws-plugin-template' ), - 'default' => (string) Options::DEFAULTS[ Options::GREETING_ORDERS ], - 'custom_attributes' => array( - 'min' => '1', - 'step' => '1', - ), - 'validate' => self::validate_greeting_orders( ... ), - ), - array( - 'type' => 'sectionend', - 'id' => Plugin::PREFIX . '_greeting_options', - ), + ...GreetingSettings::get_settings_fields(), array( 'type' => 'title', 'id' => Plugin::PREFIX . '_data_options', @@ -85,7 +49,7 @@ public function get_section_fields( string $tab_id, string $section_id ): array 'id' => Options::DELETE_DATA, 'title' => __( 'Remove all data on uninstallation', 'dws-plugin-template' ), 'desc' => __( 'Delete the plugin\'s data when it is uninstalled, so it must be configured again if it is installed later', 'dws-plugin-template' ), - 'default' => wc_bool_to_string( Options::DEFAULTS[ Options::DELETE_DATA ] ), + 'default' => Options::DEFAULTS[ Options::DELETE_DATA ], ), array( 'type' => 'sectionend', @@ -121,29 +85,4 @@ public function add_settings_tab( array $pages ): array { } // endregion - - // region HELPERS - - /** - * Returns the number of orders before the greeting shows as the digits WooCommerce stores, or an error when the submission is not a whole number of at least 1. - * - * The number input sends a whole number in any form a browser accepts, such as 03 or 3.0, so the submission parses as a float. - * - * @since 1.0.0 - * @version 1.0.0 - * - * @param mixed $submitted The submitted value. - * - * @return string|\WP_Error - */ - protected static function validate_greeting_orders( mixed $submitted ): string|\WP_Error { - $orders = \is_string( $submitted ) ? \filter_var( $submitted, \FILTER_VALIDATE_FLOAT ) : false; - if ( false === $orders || $orders < 1 || $orders >= \PHP_INT_MAX || \floor( $orders ) !== $orders ) { - return new \WP_Error( 'invalid_number', __( 'Enter a whole number of 1 or more, such as 3.', 'dws-plugin-template' ) ); - } - - return (string) (int) $orders; - } - - // endregion } diff --git a/tests/EndToEnd/settings.spec.ts b/tests/EndToEnd/settings.spec.ts index 0e0a9f0..4c9418b 100644 --- a/tests/EndToEnd/settings.spec.ts +++ b/tests/EndToEnd/settings.spec.ts @@ -1,10 +1,9 @@ import AxeBuilder from '@axe-core/playwright'; import { expect, test } from '@wordpress/e2e-test-utils-playwright'; import type { Locator, Page } from '@playwright/test'; +import { cli } from './cli'; const SAVED = 'Your settings have been saved.'; -const REJECTED = - 'Orders before the greeting was not saved and keeps its previous value. The other changes were saved. Enter a whole number of 1 or more, such as 3.'; const tabTo = async ( page: Page, target: Locator ): Promise< void > => { for ( let presses = 0; presses < 10; presses++ ) { @@ -45,7 +44,7 @@ test.describe( 'Settings tab', () => { expect( results.violations ).toEqual( [] ); } ); - test( 'saves the order threshold by keyboard, rejects an empty one and keeps the saved value until a valid one replaces it', async ( { + test( 'saves the order threshold by keyboard, and an emptied one as the default its placeholder shows', async ( { page, } ) => { const threshold = page.getByLabel( 'Orders before the greeting' ); @@ -58,17 +57,23 @@ test.describe( 'Settings tab', () => { await threshold.focus(); await typeAndSave( page, '' ); - await expect( page.getByText( REJECTED ) ).toBeVisible(); - await expect( threshold ).toHaveValue( '3' ); - const rejected = await new AxeBuilder( { page } ) + await expect( page.getByText( SAVED ) ).toBeVisible(); + await expect( threshold ).toHaveValue( '' ); + await expect( threshold ).toHaveAttribute( 'placeholder', '1' ); + expect( + cli( + 'eval', + 'var_export( get_option( "dws_tpl_greeting_orders" ) );' + ) + ).toBe( "''" ); + const emptied = await new AxeBuilder( { page } ) .include( '#mainform' ) .analyze(); - expect( rejected.violations ).toEqual( [] ); + expect( emptied.violations ).toEqual( [] ); await threshold.focus(); await typeAndSave( page, '1' ); await expect( page.getByText( SAVED ) ).toBeVisible(); - await expect( page.getByText( REJECTED ) ).toBeHidden(); await expect( threshold ).toHaveValue( '1' ); } ); } ); diff --git a/tests/Integration/MigrationsTest.php b/tests/Integration/MigrationsTest.php index ba69e02..4184a15 100644 --- a/tests/Integration/MigrationsTest.php +++ b/tests/Integration/MigrationsTest.php @@ -27,8 +27,9 @@ protected function restore_the_revision(): void { // The runner's time budget lasts for its request, and this process's bootstrap spends it when it migrates a new site, so the row runs as a request of its own. #[RunInSeparateProcess] - public function test_the_runner_brings_a_new_site_to_revision_one_with_the_untranslated_defaults_autoloaded(): void { + public function test_the_runner_brings_a_new_site_to_revision_one_with_every_default_autoloaded(): void { global $wpdb; + self::assertEqualsCanonicalizing( \array_keys( Options::DEFAULTS ), self::OPTIONS, 'OPTIONS lists every key in Options::DEFAULTS.' ); foreach ( self::OPTIONS as $key ) { delete_option( $key ); } @@ -37,11 +38,8 @@ public function test_the_runner_brings_a_new_site_to_revision_one_with_the_untra dws_tpl_plugin()->migrations?->run(); self::assertSame( 1, MigrationRunner::get_revision( self::REVISION ) ); - self::assertSame( 'no', get_option( Options::GREETING_ENABLED ) ); - self::assertSame( '1', get_option( Options::GREETING_ORDERS ) ); - self::assertSame( 'no', get_option( Options::DELETE_DATA ) ); - self::assertFalse( get_option( Options::GREETING ) ); - foreach ( array( Options::GREETING_ENABLED, Options::GREETING_ORDERS, Options::DELETE_DATA ) as $key ) { + foreach ( Options::DEFAULTS as $key => $default ) { + self::assertSame( $default, get_option( $key ) ); self::assertSame( 'on', $wpdb->get_var( $wpdb->prepare( "SELECT autoload FROM {$wpdb->options} WHERE option_name = %s", $key ) ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Reads the stored autoload flag. } } diff --git a/tests/Integration/OptionsTest.php b/tests/Integration/OptionsTest.php index 7177c1b..3ddd241 100644 --- a/tests/Integration/OptionsTest.php +++ b/tests/Integration/OptionsTest.php @@ -68,7 +68,7 @@ public function test_a_bool_reads_the_stored_checkbox_value_or_false( mixed $sto public function test_a_missing_option_reads_its_default(): void { delete_option( Options::DELETE_DATA ); - self::assertSame( Options::DEFAULTS[ Options::DELETE_DATA ], Options::get_bool( Options::DELETE_DATA ) ); + self::assertFalse( Options::get_bool( Options::DELETE_DATA ) ); } #[DataProvider( 'stored_ints' )] @@ -81,7 +81,7 @@ public function test_an_int_reads_a_whole_number_or_zero( mixed $stored, int $ex public function test_an_int_that_is_not_a_whole_number_reads_its_default(): void { update_option( Options::GREETING_ORDERS, 'seven' ); - self::assertSame( Options::DEFAULTS[ Options::GREETING_ORDERS ], Options::get_int( Options::GREETING_ORDERS ) ); + self::assertSame( 1, Options::get_int( Options::GREETING_ORDERS ) ); } #[DataProvider( 'stored_strings' )] diff --git a/tests/Integration/SettingsTest.php b/tests/Integration/SettingsTest.php index 4d5d4d7..7b6c070 100644 --- a/tests/Integration/SettingsTest.php +++ b/tests/Integration/SettingsTest.php @@ -5,7 +5,6 @@ use DeepWebSolutions\PluginTemplate\Greeting\AccountDashboardGreeting; use DeepWebSolutions\PluginTemplate\Options; use DeepWebSolutions\PluginTemplate\Plugin; -use DeepWebSolutions\PluginTemplate\Settings; use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\WooCommerce\Setting\SettingsTab; use DeepWebSolutions\PluginTemplate\Tests\Support\RestoresOptionsTrait; use PHPUnit\Framework\Attributes\After; @@ -36,12 +35,11 @@ public static function setUpBeforeClass(): void { public static function invalid_order_counts(): array { return array( - 'zero' => array( '0' ), - 'negative' => array( '-2' ), - 'decimal' => array( '2.5' ), - 'word' => array( 'three' ), - 'empty' => array( '' ), - 'too-big' => array( '1e20' ), + 'zero' => array( '0', 'Enter a number of at least 1.' ), + 'negative' => array( '-2', 'Enter a number of at least 1.' ), + 'decimal' => array( '2.5', 'Enter a number in steps of 1 from 1.' ), + 'word' => array( 'three', 'Enter a number, such as 12 or 3.5.' ), + 'too-big' => array( '1e20', 'Enter a number, such as 12 or 3.5.' ), ); } @@ -63,19 +61,6 @@ public static function whole_order_counts(): array { ); } - public static function submitted_order_counts(): array { - return array( - 'one' => array( '1', '1' ), - 'padded' => array( ' 3 ', '3' ), - 'zero' => array( '0', null ), - 'negative' => array( '-3', null ), - 'decimal' => array( '2.5', null ), - 'word' => array( 'three', null ), - 'empty' => array( '', null ), - 'array' => array( array( '3' ), null ), - ); - } - #[After] protected function forget_the_rejections(): void { new \ReflectionProperty( \WC_Admin_Settings::class, 'errors' )->setValue( null, array() ); @@ -94,9 +79,21 @@ public function test_the_tab_shows_the_greeting_and_data_fields_with_their_defau self::assertSame( AccountDashboardGreeting::get_default_message(), $form->getElementById( Options::GREETING )?->getAttribute( 'placeholder' ) ); self::assertSame( '1', $form->getElementById( Options::GREETING_ORDERS )?->getAttribute( 'value' ) ); self::assertSame( '1', $form->getElementById( Options::GREETING_ORDERS )?->getAttribute( 'min' ) ); + self::assertSame( '1', $form->getElementById( Options::GREETING_ORDERS )?->getAttribute( 'placeholder' ) ); self::assertFalse( $form->getElementById( Options::DELETE_DATA )?->hasAttribute( 'checked' ) ); } + // WooCommerce stores each field's default whenever its own version changes, so a default other than the seeded one would win or lose by which write runs first. + public function test_each_field_defaults_to_the_value_the_migrations_seed(): void { + $fields = \array_filter( self::$tab->get_settings_for_section( '' ), static fn ( array $field ): bool => ! \in_array( $field['type'], array( 'title', 'sectionend' ), true ) ); + + self::assertNotSame( array(), $fields ); + foreach ( $fields as $field ) { + self::assertArrayHasKey( $field['id'], Options::DEFAULTS ); + self::assertSame( Options::DEFAULTS[ $field['id'] ], $field['default'] ?? null, "The field {$field['id']} defaults to another value than Options::DEFAULTS holds." ); + } + } + #[DataProvider( 'field_texts' )] public function test_the_tab_words_each_field_by_what_it_does( string $key, string $title, string $description ): void { $row = (string) $this->render()->getElementById( $key )?->closest( 'tr' )?->textContent; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. @@ -127,13 +124,26 @@ public function test_the_uninstall_consent_round_trips_through_woocommerces_save } #[DataProvider( 'invalid_order_counts' )] - public function test_an_order_count_that_is_not_a_whole_number_of_at_least_one_keeps_the_stored_count( string $posted ): void { + public function test_an_order_count_that_is_not_a_whole_number_of_at_least_one_keeps_the_stored_count( string $posted, string $reason ): void { update_option( Options::GREETING_ORDERS, '4' ); $this->save( array( Options::GREETING_ORDERS => $posted ) ); self::assertSame( '4', get_option( Options::GREETING_ORDERS ) ); - self::assertSame( array( 'Orders before the greeting was not saved and keeps its previous value. The other changes were saved. Enter a whole number of 1 or more, such as 3.' ), new \ReflectionProperty( \WC_Admin_Settings::class, 'errors' )->getValue() ); + self::assertSame( array( "Orders before the greeting was not saved and keeps its previous value. The other changes were saved. $reason" ), new \ReflectionProperty( \WC_Admin_Settings::class, 'errors' )->getValue() ); + } + + public function test_an_emptied_order_count_reads_as_the_default_its_placeholder_shows(): void { + update_option( Options::GREETING_ORDERS, '4' ); + + $this->save( array( Options::GREETING_ORDERS => '' ) ); + $control = $this->render()->getElementById( Options::GREETING_ORDERS ); + + self::assertSame( '', get_option( Options::GREETING_ORDERS ) ); + self::assertSame( 1, Options::get_int( Options::GREETING_ORDERS ) ); + self::assertSame( array(), new \ReflectionProperty( \WC_Admin_Settings::class, 'errors' )->getValue() ); + self::assertSame( '', $control?->getAttribute( 'value' ) ); + self::assertSame( '1', $control?->getAttribute( 'placeholder' ) ); } #[DataProvider( 'whole_order_counts' )] @@ -144,14 +154,6 @@ public function test_a_whole_order_count_of_at_least_one_is_stored_as_its_digits self::assertSame( 3, Options::get_int( Options::GREETING_ORDERS ) ); } - #[DataProvider( 'submitted_order_counts' )] - public function test_an_order_count_validates_as_a_whole_number_of_at_least_one( mixed $submitted, ?string $expected ): void { - $field = \array_find( new Settings()->get_section_fields( Plugin::PREFIX, '' ), static fn ( array $field ): bool => Options::GREETING_ORDERS === $field['id'] ); - $validated = \is_array( $field ) && \is_callable( $field['validate'] ?? null ) ? $field['validate']( $submitted ) : null; - - \is_null( $expected ) ? self::assertInstanceOf( \WP_Error::class, $validated ) : self::assertSame( $expected, $validated ); - } - protected function render(): \Dom\HTMLDocument { \ob_start(); \WC_Admin_Settings::output_fields( self::$tab->get_settings_for_section( '' ) ); diff --git a/tests/README.md b/tests/README.md index 70f9e11..4ed707c 100644 --- a/tests/README.md +++ b/tests/README.md @@ -6,9 +6,9 @@ This document describes the plugin's three test suites, the environments they ru | Suite | Where | Runs on | Covers | | --- | --- | --- | --- | -| Unit | `tests/Unit/` | PHP alone, without WordPress | the component tree, the footprint's shape and coverage, the `@api` tag on every global function and constant, every directory's stub, and the entry's `Tested up to` and `WC tested up to` against the locked WordPress and WooCommerce | -| Integration | `tests/Integration/` | the tests site, with WordPress, WooCommerce and order tables loaded, and the multisite site for its `multisite` group | the boot, every component, the options and their readers, the migrations, deactivation, uninstall, the assets and blocks, the peer getters, a partial deploy: a missing build file or engine, or an include that throws, the safeguard recipe's own test, and the boot and settings on a second site of a network | -| End to end | `tests/EndToEnd/` | the tests site, in a browser through Playwright | the settings tab by keyboard, a rejected and a corrected value, accessibility through axe, the example block inserted from the editor's command palette and published in its Outlined style, and the plugin with WooCommerce deactivated | +| Unit | `tests/Unit/` | PHP alone, without WordPress | the component tree, the footprint's shape and coverage, the key set each seeding step stores, the `@api` tag on every global function and constant, every directory's stub, and the entry's `Tested up to` and `WC tested up to` against the locked WordPress and WooCommerce | +| Integration | `tests/Integration/` | the tests site, with WordPress, WooCommerce and order tables loaded, and the multisite site for its `multisite` group | the boot, every component, the options and their readers, each field's default against the seeded one, the migrations, deactivation, uninstall, the assets and blocks, the peer getters, a partial deploy: a missing build file or engine, or an include that throws, the safeguard recipe's own test, and the boot and settings on a second site of a network | +| End to end | `tests/EndToEnd/` | the tests site, in a browser through Playwright | the settings tab by keyboard, an emptied value read as its default, accessibility through axe, the example block inserted from the editor's command palette and published in its Outlined style, and the plugin with WooCommerce deactivated | PHPUnit runs both PHP suites in random order, so each row sets up what it needs and restores what it changes; its cache lives in `tests/.cache/phpunit/`, and Playwright's output in `tests/.cache/artifacts/`. diff --git a/tests/Unit/MigrationsTest.php b/tests/Unit/MigrationsTest.php new file mode 100644 index 0000000..68c924f --- /dev/null +++ b/tests/Unit/MigrationsTest.php @@ -0,0 +1,27 @@ + $step instanceof \Closure && 'add_settings' === new \ReflectionFunction( $step )->getName() ); + + // The fill-in renames the prefix, so each key counts by what follows it. + $keys = \array_map( static fn ( string $key ): string => \str_starts_with( $key, Plugin::PREFIX . '_' ) ? \substr( $key, \strlen( Plugin::PREFIX ) + 1 ) : $key, \array_keys( 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." ); + } +}