Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions docs/notices.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<string,string>` 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<string,string>` 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 `<p>` 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
Expand All @@ -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;
Expand All @@ -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 );
} );
```

Expand Down
16 changes: 16 additions & 0 deletions src/Notices/Contracts/Queue_Interface.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
8 changes: 3 additions & 5 deletions src/Notices/Queue.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -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();
}

/**
Expand Down
18 changes: 11 additions & 7 deletions src/Notices/Store.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,17 +24,21 @@
*/
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
*
* @throws Config_Exception When no hook prefix has been set.
*
* @return string
*/
public static function option_name(): string {
public function option_name(): string {
return Config::get_option_name( 'notices' );
}

Expand All @@ -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 [];
Expand Down Expand Up @@ -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 );
}

/**
Expand All @@ -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() );
}
}
17 changes: 17 additions & 0 deletions tests/_support/Spy_Queue.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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;
}
}
12 changes: 9 additions & 3 deletions tests/_support/Traits/WithNoticeQueue.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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;
}
Expand Down
2 changes: 1 addition & 1 deletion tests/unit/Notices/QueueTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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() );

Expand Down
4 changes: 2 additions & 2 deletions tests/unit/Notices/StoreTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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() );
}

/**
Expand All @@ -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.' );

Expand Down
Loading