Skip to content

Commit d4d954c

Browse files
committed
fix: address review feedback on the pre-release notice
- Use the core is-dismissible close button and drop the hand-rolled one. - Keep i18n one translatable string per message. - Persist dismissal keyed to PR PLUGIN_VERSION so it returns on newer builds. - Fold is_pre_release into PreReleaseNotice and allow build metadata. - Deny without capability via wp_send_json_error(403) or wp_die('', 403). - Rename the dismiss script to pre-release-notice.js and persist on X clicks.
1 parent b4717e5 commit d4d954c

5 files changed

Lines changed: 164 additions & 119 deletions

File tree

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,22 +10,31 @@
1010
for ( const notice of notices ) {
1111
notice.addEventListener( 'click', ( event ) => {
1212
const dismiss = event.target.closest(
13-
'[data-antispam-bee-dismiss], [data-antispam-bee-dismiss-link]'
13+
'.notice-dismiss, [data-antispam-bee-dismiss-link]'
1414
);
1515

1616
if ( ! dismiss ) {
1717
return;
1818
}
1919

20-
event.preventDefault();
20+
// Core already hides and removes the notice for its own close
21+
// button, so only block the Dismiss link's default navigation.
22+
if ( ! dismiss.closest( '.notice-dismiss' ) ) {
23+
event.preventDefault();
24+
}
2125

22-
const href = dismiss.dataset.antispamBeeDismissLink;
26+
const href = notice.dataset.antispamBeeDismissLink;
2327

28+
// The notice may already be gone once core has handled the close
29+
// button, so only hide it here when the AJAX call finished before
30+
// core's own removal.
2431
wp.ajax.post( antispamBeePreReleaseNotice.action, {
2532
_ajax_nonce: antispamBeePreReleaseNotice.nonce,
2633
} )
2734
.done( () => {
28-
notice.remove();
35+
if ( notice.isConnected ) {
36+
notice.style.display = 'none';
37+
}
2938
} )
3039
.fail( () => {
3140
if ( href ) {

src/Admin/PreReleaseNotice.php

Lines changed: 57 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77

88
namespace AntispamBee\Admin;
99

10-
use AntispamBee\Helpers\StringHelper;
1110
use const AntispamBee\MAIN_PLUGIN_FILE;
1211
use const AntispamBee\PLUGIN_VERSION;
1312

@@ -92,27 +91,34 @@ public static function maybe_render( string $hook_suffix = '' ): void {
9291
self::DISMISS_ACTION
9392
);
9493

94+
$version_label = wp_kses_post(
95+
sprintf(
96+
/* translators: %s: installed version, already wrapped in code tags. */
97+
__( 'You are running version %s.', 'antispam-bee' ),
98+
'<code>' . esc_html( PLUGIN_VERSION ) . '</code>'
99+
)
100+
);
101+
95102
printf(
96-
'<div class="notice notice-warning" data-antispam-bee-pre-release-notice>' .
103+
'<div class="notice notice-warning is-dismissible" data-antispam-bee-pre-release-notice data-antispam-bee-dismiss-link="%5$s">' .
97104
'<p><strong>%1$s</strong></p>' .
98-
'<p>%2$s <code>%3$s</code></p>' .
99-
'<p>%4$s</p>' .
100-
'<p><a class="button" href="%5$s" target="_blank" rel="noopener noreferrer">%6$s</a> ' .
101-
'<a class="button-link" href="%7$s" data-antispam-bee-dismiss-link="%7$s">%8$s</a></p>' .
102-
'<button type="button" class="notice-dismiss" data-antispam-bee-dismiss aria-label="%9$s"><span class="screen-reader-text">%9$s</span></button>' .
105+
'<p>%2$s</p>' .
106+
'<p>%3$s</p>' .
107+
'<p><a class="button" href="%4$s" target="_blank" rel="noopener noreferrer">%6$s</a> ' .
108+
'<a class="button-link" href="%5$s">%7$s</a></p>' .
103109
'</div>',
104110
esc_html__( 'Antispam Bee is a pre-release version', 'antispam-bee' ),
105-
esc_html__( 'You are running version', 'antispam-bee' ),
106-
esc_html( PLUGIN_VERSION ),
111+
// The version label is sanitized by wp_kses_post() above.
112+
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
113+
$version_label,
107114
esc_html__(
108115
'This is a pre-release and not intended for production. Please test it and report any issues you find.',
109116
'antispam-bee'
110117
),
111118
esc_url( self::FEEDBACK_URL ),
112-
esc_html__( 'Report a bug', 'antispam-bee' ),
113119
esc_url( $dismiss_url ),
114-
esc_html__( 'Dismiss', 'antispam-bee' ),
115-
esc_html__( 'Dismiss this notice', 'antispam-bee' )
120+
esc_html__( 'Report a bug', 'antispam-bee' ),
121+
esc_html__( 'Dismiss', 'antispam-bee' )
116122
);
117123
}
118124

@@ -128,7 +134,7 @@ public static function maybe_enqueue_assets( string $hook_suffix = '' ): void {
128134

129135
wp_enqueue_script(
130136
'antispam-bee-pre-release-notice',
131-
plugin_dir_url( MAIN_PLUGIN_FILE ) . 'assets/js/admin-notice.js',
137+
plugin_dir_url( MAIN_PLUGIN_FILE ) . 'assets/js/pre-release-notice.js',
132138
[ 'wp-util' ],
133139
PLUGIN_VERSION,
134140
true
@@ -152,7 +158,7 @@ public static function maybe_enqueue_assets( string $hook_suffix = '' ): void {
152158
* @return bool Whether to show the notice.
153159
*/
154160
private static function should_show( string $hook_suffix ): bool {
155-
if ( ! StringHelper::is_pre_release( PLUGIN_VERSION ) ) {
161+
if ( ! self::is_pre_release( PLUGIN_VERSION ) ) {
156162
return false;
157163
}
158164

@@ -164,33 +170,61 @@ private static function should_show( string $hook_suffix ): bool {
164170
return false;
165171
}
166172

167-
return false === (bool) get_user_meta( get_current_user_id(), self::DISMISSED_META_KEY, true );
173+
return get_user_meta( get_current_user_id(), self::DISMISSED_META_KEY, true ) !== PLUGIN_VERSION;
174+
}
175+
176+
/**
177+
* Whether a plugin version string marks a pre-release.
178+
*
179+
* A version is a pre-release if the measured number is followed by a
180+
* semantic versioning pre-release suffix, e.g. `3.0.0-RC.1` or
181+
* `3.0.0-beta.2`. The stable `3.0.0` has no such suffix. Build metadata
182+
* after the pre-release suffix, e.g. `3.0.0-beta.2+build`, still marks a
183+
* pre-release.
184+
*
185+
* @param string $version The version string.
186+
*
187+
* @return bool Whether the version is a pre-release.
188+
*/
189+
public static function is_pre_release( string $version ): bool {
190+
return 1 === preg_match( '/^[0-9]+(?:\.[0-9]+){0,2}-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/', $version );
168191
}
169192

170193
/**
171194
* Persist the dismissal, then acknowledge an AJAX request or redirect.
172195
*
173-
* Both `wp_send_json_success()` and the redirect end the request, so the
174-
* non-AJAX branch is the only one that reaches the redirect and `exit`.
196+
* `wp_send_json_success()` ends an AJAX request, so the non-AJAX branch is
197+
* the only one that reaches the redirect and `exit`.
175198
*/
176199
public static function handle_dismiss(): void {
177200
if ( ! current_user_can( 'manage_options' ) ) {
178-
wp_die( esc_html__( 'You do not have permission to do this.', 'antispam-bee' ), 403 );
201+
$message = esc_html__( 'You do not have permission to do this.', 'antispam-bee' );
202+
203+
if ( wp_doing_ajax() ) {
204+
wp_send_json_error( $message, 403 );
205+
}
206+
207+
// The message is escaped by esc_html__() above.
208+
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
209+
wp_die( $message, '', 403 );
179210
}
180211

181212
$is_ajax = wp_doing_ajax();
182213

183214
if ( $is_ajax ) {
184215
check_ajax_referer( self::DISMISS_ACTION );
185-
update_user_meta( get_current_user_id(), self::DISMISSED_META_KEY, 1 );
186-
wp_send_json_success();
187216
} else {
188217
check_admin_referer( self::DISMISS_ACTION );
189-
update_user_meta( get_current_user_id(), self::DISMISSED_META_KEY, 1 );
218+
}
190219

191-
wp_safe_redirect( wp_get_referer() ? wp_get_referer() : admin_url() );
220+
update_user_meta( get_current_user_id(), self::DISMISSED_META_KEY, PLUGIN_VERSION );
192221

193-
exit;
222+
if ( $is_ajax ) {
223+
wp_send_json_success();
194224
}
225+
226+
wp_safe_redirect( wp_get_referer() ? wp_get_referer() : admin_url() );
227+
228+
exit;
195229
}
196230
}

src/Helpers/StringHelper.php

Lines changed: 0 additions & 29 deletions
This file was deleted.

tests/Unit/Admin/PreReleaseNoticeTest.php

Lines changed: 94 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -22,16 +22,21 @@ class PreReleaseNoticeTest extends TestCase {
2222
* @param bool $can_manage Whether the mock user may manage options.
2323
* @param bool $dismissed Whether the mock user dismissed the notice.
2424
*/
25-
private function mock_dependencies( bool $can_manage = true, bool $dismissed = false ): void {
25+
private function mock_dependencies( bool $can_manage = true, ?string $dismissed_version = null ): void {
2626
when( 'esc_html' )->returnArg();
2727
when( 'esc_url' )->returnArg();
28+
when( 'wp_kses_post' )->returnArg();
2829
when( 'wp_nonce_url' )->returnArg();
2930
when( 'admin_url' )->returnArg();
3031
when( 'current_user_can' )->justReturn( $can_manage );
3132
when( 'get_current_user_id' )->justReturn( 1 );
3233
when( 'get_user_meta' )->alias(
33-
static function ( $id, $key ) use ( $dismissed ) {
34-
return $key === PreReleaseNotice::DISMISSED_META_KEY ? $dismissed : false;
34+
static function ( $id, $key ) use ( $dismissed_version ) {
35+
if ( $key !== PreReleaseNotice::DISMISSED_META_KEY ) {
36+
return false;
37+
}
38+
39+
return null === $dismissed_version ? '' : $dismissed_version;
3540
}
3641
);
3742
when( 'plugin_dir_url' )->returnArg();
@@ -95,19 +100,19 @@ private function render_for( string $hook_suffix ): string {
95100
public function test_renders_on_the_settings_page(): void {
96101
self::mock_dependencies();
97102

98-
self::assertStringContainsString(
99-
'pre-release',
100-
self::render_for( 'settings_page_antispam_bee' )
101-
);
103+
$output = self::render_for( 'settings_page_antispam_bee' );
104+
105+
self::assertStringContainsString( 'pre-release', $output );
106+
self::assertStringContainsString( 'is-dismissible', $output );
107+
self::assertStringContainsString( '<code>3.0.0-beta.2</code>', $output );
108+
self::assertStringNotContainsString( 'data-antispam-bee-dismiss ', $output );
109+
self::assertStringNotContainsString( '<button type="button" class="notice-dismiss"', $output );
102110
}
103111

104112
public function test_renders_on_the_plugins_list(): void {
105113
self::mock_dependencies();
106114

107-
self::assertStringContainsString(
108-
'pre-release',
109-
self::render_for( 'plugins.php' )
110-
);
115+
self::assertStringContainsString( 'pre-release', self::render_for( 'plugins.php' ) );
111116
}
112117

113118
public function test_renders_using_the_global_hook_suffix(): void {
@@ -154,12 +159,18 @@ public function test_does_not_render_without_manage_options_capability(): void {
154159
self::assertSame( '', self::render_for( 'plugins.php' ) );
155160
}
156161

157-
public function test_does_not_render_when_dismissed_by_the_user(): void {
158-
self::mock_dependencies( true, true );
162+
public function test_does_not_render_when_dismissed_for_the_installed_version(): void {
163+
self::mock_dependencies( true, '3.0.0-beta.2' );
159164

160165
self::assertSame( '', self::render_for( 'plugins.php' ) );
161166
}
162167

168+
public function test_renders_again_for_a_newer_prerelease_version(): void {
169+
self::mock_dependencies( true, '3.0.0-RC.1' );
170+
171+
self::assertStringContainsString( 'pre-release', self::render_for( 'plugins.php' ) );
172+
}
173+
163174
public function test_enqueues_assets_on_the_plugins_list(): void {
164175
self::mock_dependencies();
165176
$enqueued = [];
@@ -207,12 +218,18 @@ static function ( $id, $key, $value ) use ( &$updated ) {
207218
when( 'wp_send_json_success' )->alias(
208219
static function () use ( &$sent ) {
209220
$sent = true;
221+
222+
throw new RuntimeException( '__ANTISPAM_BEE_EXPECTED_HALT__' );
210223
}
211224
);
212225

213-
PreReleaseNotice::handle_dismiss();
226+
self::assert_and_terminates(
227+
static function () {
228+
PreReleaseNotice::handle_dismiss();
229+
}
230+
);
214231

215-
self::assertSame( [ [ 1, PreReleaseNotice::DISMISSED_META_KEY, 1 ] ], $updated );
232+
self::assertSame( [ [ 1, PreReleaseNotice::DISMISSED_META_KEY, \AntispamBee\PLUGIN_VERSION ] ], $updated );
216233
self::assertTrue( $sent, 'The AJAX request must be acknowledged' );
217234
}
218235

@@ -243,14 +260,38 @@ static function () {
243260
}
244261
);
245262

246-
self::assertSame( [ [ 1, PreReleaseNotice::DISMISSED_META_KEY, 1 ] ], $updated );
263+
self::assertSame( [ [ 1, PreReleaseNotice::DISMISSED_META_KEY, \AntispamBee\PLUGIN_VERSION ] ], $updated );
247264
self::assertSame( 'https://example.com/wp-admin/plugins.php', $target );
248265
}
249266

250-
public function test_handle_dismiss_denies_without_capability(): void {
267+
public function test_handle_dismiss_denies_ajax_without_capability(): void {
251268
self::mock_dependencies( false );
252269
when( 'wp_doing_ajax' )->justReturn( true );
253270

271+
$captured = null;
272+
when( 'wp_send_json_error' )->alias(
273+
static function ( ...$args ) use ( &$captured ) {
274+
$captured = $args;
275+
276+
throw new RuntimeException( '__ANTISPAM_BEE_EXPECTED_HALT__' );
277+
}
278+
);
279+
280+
self::assert_and_terminates(
281+
static function () {
282+
PreReleaseNotice::handle_dismiss();
283+
}
284+
);
285+
286+
self::assertNotNull( $captured, 'The handler must refuse users without the capability' );
287+
self::assertCount( 2, $captured );
288+
self::assertSame( 403, $captured[1], 'The AJAX denial must carry an HTTP 403 status' );
289+
}
290+
291+
public function test_handle_dismiss_denies_form_request_without_capability(): void {
292+
self::mock_dependencies( false );
293+
when( 'wp_doing_ajax' )->justReturn( false );
294+
254295
$captured = null;
255296
self::stub_terminator( 'wp_die', static function ( ...$args ) use ( &$captured ) {
256297
$captured = $args;
@@ -263,6 +304,41 @@ static function () {
263304
);
264305

265306
self::assertNotNull( $captured, 'The handler must refuse users without the capability' );
266-
self::assertSame( 403, $captured[1] );
307+
self::assertCount( 3, $captured );
308+
self::assertSame( '', $captured[1], 'The page title must be an empty string' );
309+
self::assertSame( 403, $captured[2], 'The non-AJAX denial must carry an HTTP 403 status' );
310+
}
311+
312+
/**
313+
* Data provider for pre-release detection.
314+
*
315+
* @return array<string, array{string, bool}>
316+
*/
317+
public static function data_is_pre_release(): array {
318+
return [
319+
'stable 3.0.0' => [ '3.0.0', false ],
320+
'stable 1.2.3' => [ '1.2.3', false ],
321+
'stable with prefix' => [ 'v2.11.13', false ],
322+
'rc suffix' => [ '3.0.0-RC.1', true ],
323+
'beta suffix' => [ '3.0.0-beta.2', true ],
324+
'alpha suffix' => [ '3.0.0-alpha', true ],
325+
'mixed case suffix' => [ '3.0.0-Rc1', true ],
326+
'build metadata' => [ '3.0.0-beta.2+build', true ],
327+
'empty string' => [ '', false ],
328+
'not a version' => [ 'foo', false ],
329+
'suffix without number' => [ '-beta', false ],
330+
];
331+
}
332+
333+
/**
334+
* Test is_pre_release() against a range of version strings.
335+
*
336+
* @param string $version Version string.
337+
* @param bool $expected Expected result.
338+
*
339+
* @dataProvider data_is_pre_release
340+
*/
341+
public function test_is_pre_release( string $version, bool $expected ): void {
342+
self::assertSame( $expected, PreReleaseNotice::is_pre_release( $version ) );
267343
}
268344
}

0 commit comments

Comments
 (0)