diff --git a/docs/notices.md b/docs/notices.md index 304f11b..8dcb5f0 100644 --- a/docs/notices.md +++ b/docs/notices.md @@ -23,15 +23,16 @@ administrator, not the site administrator who installed the plugin, who sees the ## Rendering them yourself -`Notices\Queue::option_name()` is public, so you can render the queue yourself without replacing -anything. The value is an `array` keyed `slug:type` — `give-recurring:merge`, for +`Absorber::notices()->option_name()` tells you where the queue is kept, so you can render it +yourself without replacing anything — and it answers for whichever queue the site is running, so a +rebound implementation keeping its notices elsewhere still gives you the right name. The value is an `array` keyed `slug:type` — `give-recurring:merge`, for example — and the messages may contain markup; the default rendering passes them through `wp_kses_post()`, so a link, emphasis or a list survives while scripts and event handlers are stripped. Paragraphs come from `wpautop()`, so send the message unwrapped and let a blank line break it — a `

` of your own is left as it is rather than nested inside another. ```php -use Nexcess\PluginAbsorber\Notices\Queue; +use Nexcess\PluginAbsorber\Absorber; add_action( 'admin_init', function () { // Gates the read, not just the delete: `admin_init` fires for every logged-in user, and @@ -41,7 +42,8 @@ add_action( 'admin_init', function () { return; } - $notices = get_site_option( Queue::option_name(), [] ); + $option = Absorber::notices()->option_name(); + $notices = get_site_option( $option, [] ); if ( ! is_array( $notices ) || ! $notices ) { return; @@ -51,7 +53,7 @@ add_action( 'admin_init', function () { my_plugin_enqueue_notice( $key, $message ); } - delete_site_option( Queue::option_name() ); + delete_site_option( $option ); } ); ``` diff --git a/src/Notices/Contracts/Queue_Interface.php b/src/Notices/Contracts/Queue_Interface.php index e3cf04a..e2b423e 100644 --- a/src/Notices/Contracts/Queue_Interface.php +++ b/src/Notices/Contracts/Queue_Interface.php @@ -85,4 +85,20 @@ public function queue_dependency_notice( Sub_Plugin $sub_plugin ): void; * @return void */ public function render(): void; + + /** + * Where these notices are kept, so a host can render them itself without replacing the queue. + * + * On the contract rather than on the default implementation, and an instance method rather than + * a static one, because the honest answer depends on which queue a site is running: an + * implementation bound in place of the default keeps its notices where it likes, and a host + * reading a name off the default class would read an option nothing writes to. + * + * @since 1.0.0 + * + * @throws Config_Exception When no hook prefix has been set. + * + * @return string + */ + public function option_name(): string; } diff --git a/src/Notices/Queue.php b/src/Notices/Queue.php index dd34180..b122a0c 100644 --- a/src/Notices/Queue.php +++ b/src/Notices/Queue.php @@ -21,7 +21,7 @@ * bypasses every binding a host made, and it is the one a test or a stray `new` reaches for. * * A host already using stellarwp/admin-notices can bind its own implementation of Queue_Interface - * and read the same option, whose name is `self::option_name()`. + * and read the same option, whose name is `option_name()`. * * @since 1.0.0 */ @@ -178,16 +178,14 @@ public function render(): void { } /** - * The option name backing the queue. Read it directly to render these notices yourself. - * * @since 1.0.0 * * @throws Config_Exception When no hook prefix has been set. * * @return string */ - public static function option_name(): string { - return Store::option_name(); + public function option_name(): string { + return $this->store->option_name(); } /** diff --git a/src/Notices/Store.php b/src/Notices/Store.php index 23297db..e7890c0 100644 --- a/src/Notices/Store.php +++ b/src/Notices/Store.php @@ -24,9 +24,13 @@ */ class Store { /** - * The option name backing the queue. Read it directly to render these notices yourself — and - * read it from here rather than composing it, since the hook prefix is normalised on its way - * into a storage key. + * The option name backing the queue. Read it from here rather than composing it, since the hook + * prefix is normalised on its way into a storage key. + * + * An instance method, not a static one: it is the answer for *this* store, and a host that binds + * a queue keeping its notices somewhere else has to be able to give a different one. A static + * would answer for the default implementation whatever the site actually uses, which is the + * wrong answer stated with confidence. * * @since 1.0.0 * @@ -34,7 +38,7 @@ class Store { * * @return string */ - public static function option_name(): string { + public function option_name(): string { return Config::get_option_name( 'notices' ); } @@ -50,7 +54,7 @@ public static function option_name(): string { public function all(): array { // Outside multisite `get_site_option()` is `get_option()`, so this reads back whatever // put() wrote on either install type. - $queue = get_site_option( self::option_name(), [] ); + $queue = get_site_option( $this->option_name(), [] ); if ( ! is_array( $queue ) ) { return []; @@ -82,7 +86,7 @@ public function put( string $key, string $message ): void { // `update_option( $option, $value, false )`, or `add_option( $option, $value, '', false )` // the first time — either way autoload is off, which is exactly what this queue wants: it // is empty on almost every request and only ever read in the admin. - update_site_option( self::option_name(), $queue ); + update_site_option( $this->option_name(), $queue ); } /** @@ -94,6 +98,6 @@ public function put( string $key, string $message ): void { */ public function clear(): void { // Outside multisite `delete_site_option()` is `delete_option()`. - delete_site_option( self::option_name() ); + delete_site_option( $this->option_name() ); } } diff --git a/tests/_support/Spy_Queue.php b/tests/_support/Spy_Queue.php index 2b42400..da97a1b 100644 --- a/tests/_support/Spy_Queue.php +++ b/tests/_support/Spy_Queue.php @@ -21,6 +21,16 @@ * @since 1.0.0 */ class Spy_Queue implements Queue_Interface { + /** + * The option this spy would keep notices in, if it kept any. + * + * Deliberately not the default queue's name: a test that reads the real option while a spy is + * bound is reading somewhere nothing was written, and should say so rather than agree. + * + * @var string + */ + public $option = 'spy_queue_notices'; + /** * Slugs handed to queue_merge_notice(), in order. * @@ -82,4 +92,11 @@ public function queue_dependency_notice( Sub_Plugin $sub_plugin ): void { public function render(): void { ++$this->render_calls; } + + /** + * @return string + */ + public function option_name(): string { + return $this->option; + } } diff --git a/tests/_support/Traits/WithNoticeQueue.php b/tests/_support/Traits/WithNoticeQueue.php index 7fee4b6..fac0b17 100644 --- a/tests/_support/Traits/WithNoticeQueue.php +++ b/tests/_support/Traits/WithNoticeQueue.php @@ -10,17 +10,21 @@ namespace Nexcess\PluginAbsorber\Tests\Support\Traits; use Nexcess\PluginAbsorber\Exceptions\Config_Exception; -use Nexcess\PluginAbsorber\Notices\Queue; +use Nexcess\PluginAbsorber\Config; +use Nexcess\PluginAbsorber\Notices\Store; /** * One shared way to read and clear the queue, for every test that only cares about what landed in it. * - * The name comes from `Queue::option_name()` rather than from a literal, because the literal was + * The name comes from the bound `Notices\Store` rather than from a literal, because the literal was * copied into three unrelated test classes: renaming the option — or the segment `Config` builds * between the host's prefix and the key — then means finding all of them, and the one that is missed * asserts against an option nothing writes, which reads as "no notice was queued" rather than as a * failure to keep up. * + * The store rather than the queue, deliberately: this reads where the *default* queue keeps its + * notices, so a test that binds its own queue and then finds nothing here has learnt something. + * * A test *about* the name pins it against a literal instead, and keeps its own reader. Reading it * from here as well would move both sides of that assertion together. * @@ -86,7 +90,9 @@ protected function clear_notices(): void { */ private function notice_option_name(): ?string { try { - return Queue::option_name(); + $store = Config::get_container()->get( Store::class ); + + return $store instanceof Store ? $store->option_name() : null; } catch ( Config_Exception $exception ) { return null; } diff --git a/tests/unit/Notices/QueueTest.php b/tests/unit/Notices/QueueTest.php index 8551bd4..8cbb5fd 100644 --- a/tests/unit/Notices/QueueTest.php +++ b/tests/unit/Notices/QueueTest.php @@ -553,7 +553,7 @@ public function test_the_option_is_keyed_by_the_hook_prefix(): void { Config_State::reset(); Config::set_hook_prefix( 'woo' ); - $this->assertSame( self::OPTION_FOR_OTHER_PREFIX, Queue::option_name() ); + $this->assertSame( self::OPTION_FOR_OTHER_PREFIX, $this->make_queue()->option_name() ); $this->make_queue()->queue_merge_notice( $this->make_sub_plugin() ); diff --git a/tests/unit/Notices/StoreTest.php b/tests/unit/Notices/StoreTest.php index 464c0eb..2e5f32b 100644 --- a/tests/unit/Notices/StoreTest.php +++ b/tests/unit/Notices/StoreTest.php @@ -136,7 +136,7 @@ public function test_the_option_is_keyed_by_the_hook_prefix(): void { Config_State::reset(); Config::set_hook_prefix( 'woo' ); - $this->assertSame( self::OPTION_WOO, Store::option_name() ); + $this->assertSame( self::OPTION_WOO, ( new Store() )->option_name() ); } /** @@ -147,7 +147,7 @@ public function test_the_option_name_normalises_the_hook_prefix(): void { Config_State::reset(); Config::set_hook_prefix( 'Give-Core' ); - $this->assertSame( self::OPTION_NORMALISED, Store::option_name() ); + $this->assertSame( self::OPTION_NORMALISED, ( new Store() )->option_name() ); ( new Store() )->put( 'give-recurring:merge', 'Bundled now.' );