From b84c2bd7ef702adf7213ca6c51a0d6bf0307a1d4 Mon Sep 17 00:00:00 2001 From: Nikolay Strikhar Date: Mon, 3 Aug 2026 15:41:03 +0200 Subject: [PATCH 1/3] Add the Notices queue and its interface --- README.md | 1 + src/Contracts/Notices_Interface.php | 60 +++++++ src/Loader.php | 12 ++ src/Notices.php | 169 ++++++++++++++++++++ tests/unit/NoticesTest.php | 236 ++++++++++++++++++++++++++++ 5 files changed, 478 insertions(+) create mode 100644 src/Contracts/Notices_Interface.php create mode 100644 src/Notices.php create mode 100644 tests/unit/NoticesTest.php diff --git a/README.md b/README.md index a463080..a16916f 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,7 @@ Config::set_container( $container ); | Interface | Default | Responsibility | |---|---|---| | `Contracts\Registrar_Interface` | `Registrar` | Holds the registered sub-plugins. | +| `Contracts\Notices_Interface` | `Notices` | Notice queue and rendering. | **Set the container before your first `Loader::register()` call.** Resolution is memoized, so a container set afterwards is not consulted. `Loader::reset()` does discard the memo — but it also diff --git a/src/Contracts/Notices_Interface.php b/src/Contracts/Notices_Interface.php new file mode 100644 index 0000000..0843884 --- /dev/null +++ b/src/Contracts/Notices_Interface.php @@ -0,0 +1,60 @@ +queue( + $sub_plugin, + self::TYPE_MERGE, + $sub_plugin->get_conflict_notice_message( + sprintf( + '%s has been deactivated because it is now bundled and loaded automatically.', + $sub_plugin->get_slug() + ) + ) + ); + } + + /** + * The default differs from the merge notice's on purpose: this one asks the user to act, + * where that one reports something already done. + * + * @since 1.0.0 + * + * @param Sub_Plugin $sub_plugin Sub-plugin concerned. + * + * @return void + */ + public function queue_conflict_notice( Sub_Plugin $sub_plugin ): void { + $this->queue( + $sub_plugin, + self::TYPE_CONFLICT, + $sub_plugin->get_conflict_notice_message( + sprintf( + '%s is now bundled and loaded automatically. You can safely deactivate the standalone plugin.', + $sub_plugin->get_slug() + ) + ) + ); + } + + /** + * @since 1.0.0 + * + * @param Sub_Plugin $sub_plugin Sub-plugin concerned. + * + * @return void + */ + public function queue_dependency_notice( Sub_Plugin $sub_plugin ): void { + $this->queue( $sub_plugin, self::TYPE_DEPENDENCY, $sub_plugin->get_dependency_notice_message() ); + } + + /** + * @since 1.0.0 + * + * @return void + */ + public function render(): void { + $queue = $this->get_queue(); + + if ( $queue === [] ) { + return; + } + + delete_transient( $this->transient_name() ); + + foreach ( $queue as $message ) { + if ( $message === '' ) { + continue; + } + + printf( + '

%s

', + esc_html( $message ) + ); + } + } + + /** + * Store one notice, keyed by slug and type so different types can coexist. + * + * A sub-plugin can legitimately earn a merge notice while the conflict is resolved and a + * dependency notice while the load is attempted, in the same request. Keying by slug alone + * would silently drop one of them. + * + * @since 1.0.0 + * + * @param Sub_Plugin $sub_plugin Sub-plugin concerned. + * @param string $type Notice type. + * @param string $message Resolved message. + * + * @return void + */ + private function queue( Sub_Plugin $sub_plugin, string $type, string $message ): void { + $queue = $this->get_queue(); + + $queue[ $sub_plugin->get_slug() . ':' . $type ] = $message; + + // No expiry: the queue has to outlive the redirect and wait for the next admin load. + set_transient( $this->transient_name(), $queue, 0 ); + } + + /** + * @since 1.0.0 + * + * @return array + */ + private function get_queue(): array { + $queue = get_transient( $this->transient_name() ); + + if ( ! is_array( $queue ) ) { + return []; + } + + // The store is shared with whatever else can write an option, and the render path prints + // what it finds. Anything that is not a string message is dropped rather than coerced. + return array_filter( $queue, 'is_string' ); + } + + /** + * @since 1.0.0 + * + * @return string + */ + private function transient_name(): string { + return Config::get_hook_prefix() . '_plugin_absorber_notices'; + } +} diff --git a/tests/unit/NoticesTest.php b/tests/unit/NoticesTest.php new file mode 100644 index 0000000..74b900b --- /dev/null +++ b/tests/unit/NoticesTest.php @@ -0,0 +1,236 @@ + $overrides Config overrides. + */ + private function make_sub_plugin( array $overrides = [] ): Sub_Plugin { + return new Sub_Plugin( + array_merge( + [ + 'slug' => 'give-recurring', + 'bundled_plugin_file' => '/tmp/give-recurring.php', + 'plugin_loaded_constant' => 'GIVE_RECURRING_VERSION_NOTICES', + ], + $overrides + ) + ); + } + + private function render_to_string( Notices $notices ): string { + ob_start(); + $notices->render(); + + return (string) ob_get_clean(); + } + + public function test_the_loader_resolves_the_default_notices(): void { + $this->assertInstanceOf( Notices::class, Loader::notices() ); + } + + public function test_the_default_notices_satisfy_the_contract(): void { + $this->assertInstanceOf( Notices_Interface::class, new Notices() ); + } + + public function test_it_queues_a_merge_notice_into_the_transient(): void { + ( new Notices() )->queue_merge_notice( $this->make_sub_plugin( [ 'conflict_notice_message' => 'Bundled now.' ] ) ); + + $queue = get_transient( self::TRANSIENT ); + + $this->assertIsArray( $queue ); + $this->assertArrayHasKey( 'give-recurring:merge', $queue ); + $this->assertSame( 'Bundled now.', $queue['give-recurring:merge'] ); + } + + public function test_the_merge_notice_falls_back_to_a_default_message(): void { + ( new Notices() )->queue_merge_notice( $this->make_sub_plugin() ); + + $queue = get_transient( self::TRANSIENT ); + + $this->assertStringContainsString( 'give-recurring', $queue['give-recurring:merge'] ); + $this->assertNotSame( '', $queue['give-recurring:merge'] ); + } + + public function test_it_queues_a_conflict_notice(): void { + ( new Notices() )->queue_conflict_notice( $this->make_sub_plugin() ); + + $this->assertArrayHasKey( 'give-recurring:conflict', get_transient( self::TRANSIENT ) ); + } + + /** + * The two conflict-flavoured notices say opposite things — one reports a deactivation that + * already happened, the other asks the user to do it. Sharing a default would be wrong. + */ + public function test_the_merge_and_conflict_defaults_differ(): void { + $notices = new Notices(); + $notices->queue_merge_notice( $this->make_sub_plugin() ); + $notices->queue_conflict_notice( $this->make_sub_plugin() ); + + $queue = get_transient( self::TRANSIENT ); + + $this->assertNotSame( $queue['give-recurring:merge'], $queue['give-recurring:conflict'] ); + } + + public function test_a_configured_message_is_used_for_both_conflict_types(): void { + $notices = new Notices(); + $notices->queue_merge_notice( $this->make_sub_plugin( [ 'conflict_notice_message' => 'Ours.' ] ) ); + $notices->queue_conflict_notice( $this->make_sub_plugin( [ 'conflict_notice_message' => 'Ours.' ] ) ); + + $queue = get_transient( self::TRANSIENT ); + + $this->assertSame( 'Ours.', $queue['give-recurring:merge'] ); + $this->assertSame( 'Ours.', $queue['give-recurring:conflict'] ); + } + + public function test_it_queues_a_dependency_notice_using_the_sub_plugin_message(): void { + ( new Notices() )->queue_dependency_notice( $this->make_sub_plugin( [ 'dependency_notice_message' => 'Needs Give.' ] ) ); + + $this->assertSame( 'Needs Give.', get_transient( self::TRANSIENT )['give-recurring:dependency'] ); + } + + public function test_the_dependency_notice_falls_back_to_the_sub_plugin_default(): void { + ( new Notices() )->queue_dependency_notice( $this->make_sub_plugin() ); + + $this->assertSame( + 'give-recurring could not be loaded because its requirements are not met.', + get_transient( self::TRANSIENT )['give-recurring:dependency'] + ); + } + + public function test_queueing_the_same_slug_and_type_twice_does_not_duplicate(): void { + $notices = new Notices(); + $notices->queue_merge_notice( $this->make_sub_plugin() ); + $notices->queue_merge_notice( $this->make_sub_plugin() ); + + $this->assertCount( 1, get_transient( self::TRANSIENT ) ); + } + + public function test_one_slug_can_hold_notices_of_different_types(): void { + $notices = new Notices(); + $notices->queue_merge_notice( $this->make_sub_plugin() ); + $notices->queue_dependency_notice( $this->make_sub_plugin() ); + + $this->assertCount( 2, get_transient( self::TRANSIENT ) ); + } + + public function test_different_slugs_do_not_collide(): void { + $notices = new Notices(); + $notices->queue_merge_notice( $this->make_sub_plugin() ); + $notices->queue_merge_notice( $this->make_sub_plugin( [ 'slug' => 'give-fee-recovery' ] ) ); + + $queue = get_transient( self::TRANSIENT ); + + $this->assertCount( 2, $queue ); + $this->assertArrayHasKey( 'give-recurring:merge', $queue ); + $this->assertArrayHasKey( 'give-fee-recovery:merge', $queue ); + } + + public function test_render_outputs_dismissible_warning_markup(): void { + $notices = new Notices(); + $notices->queue_merge_notice( $this->make_sub_plugin( [ 'conflict_notice_message' => 'Bundled now.' ] ) ); + + $output = $this->render_to_string( $notices ); + + $this->assertStringContainsString( 'notice notice-warning is-dismissible', $output ); + $this->assertStringContainsString( 'Bundled now.', $output ); + } + + public function test_render_escapes_the_message(): void { + $notices = new Notices(); + $notices->queue_merge_notice( $this->make_sub_plugin( [ 'conflict_notice_message' => '' ] ) ); + + $output = $this->render_to_string( $notices ); + + $this->assertStringNotContainsString( '