Skip to content

Commit 6f19699

Browse files
authored
feat(updates): [SITE-5883] Add dismiss option to WordPress update notice (#121)
1 parent 6250db3 commit 6f19699

7 files changed

Lines changed: 323 additions & 4 deletions

File tree

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/**
2+
* Persist dismissal of the Pantheon update notice.
3+
*
4+
* WordPress's native `is-dismissible` handler only hides the notice in the DOM;
5+
* it makes no server call, so the notice returns on the next page load. This
6+
* listens for the same dismiss click and records it server-side (per user, keyed
7+
* to the current WordPress version) so the notice stays dismissed until a newer
8+
* version is available.
9+
*/
10+
/* global pantheonUpdateNotice */
11+
( function () {
12+
document.addEventListener( 'click', function ( event ) {
13+
var button = event.target.closest( '.notice-dismiss' );
14+
if ( ! button ) {
15+
return;
16+
}
17+
18+
if ( ! button.closest( '#pantheon-update-notice' ) ) {
19+
return;
20+
}
21+
22+
var data = new FormData();
23+
data.append( 'action', pantheonUpdateNotice.action );
24+
data.append( 'nonce', pantheonUpdateNotice.nonce );
25+
26+
fetch( pantheonUpdateNotice.ajaxUrl, {
27+
method: 'POST',
28+
credentials: 'same-origin',
29+
body: data,
30+
} );
31+
} );
32+
}() );

inc/pantheon-updates.php

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,15 @@
55
* Handles modifying the default WordPress update behavior on Pantheon.
66
*/
77

8+
/*
9+
* User-meta key storing the WordPress version a user dismissed the update notice for.
10+
* A newer available version no longer matches the stored value, so the notice returns.
11+
*/
12+
const PANTHEON_UPDATE_NOTICE_DISMISSED_META = 'pantheon_dismissed_update_notice';
13+
14+
// AJAX action + nonce used by the dismiss handler and its front-end script.
15+
const PANTHEON_UPDATE_NOTICE_DISMISS_ACTION = 'pantheon_dismiss_update_notice';
16+
817
// If on Pantheon...
918
if ( isset( $_ENV['PANTHEON_ENVIRONMENT'] ) ) {
1019
// Disable WordPress auto updates.
@@ -108,6 +117,13 @@ function _pantheon_upstream_update_notice() {
108117

109118
// If core update is available, show the update notice on ALL pages.
110119
if ( $core_update_available ) {
120+
// Skip the notice if this user already dismissed it for the current available version.
121+
$available_version = _pantheon_get_latest_wordpress_version();
122+
$dismissed_version = get_user_meta( get_current_user_id(), PANTHEON_UPDATE_NOTICE_DISMISSED_META, true );
123+
if ( $available_version && $dismissed_version === $available_version ) {
124+
return;
125+
}
126+
111127
$message = sprintf(
112128
// translators: %s is a link to the Pantheon upstream updates documentation.
113129
__( 'For details on applying updates, see the <a href="%s">Applying Upstream Updates</a> documentation. If you need help, contact an administrator for your Pantheon organization.', 'pantheon-systems' ),
@@ -122,6 +138,7 @@ function _pantheon_upstream_update_notice() {
122138
'button_url' => $dashboard_url,
123139
'id' => 'pantheon-update-notice',
124140
'extra_classes' => 'pantheon-update-notice',
141+
'dismissible' => true,
125142
] );
126143
} elseif ( $is_update_page ) {
127144
// If no update is available but we're on the update pages, show the "Check for updates" message.
@@ -183,6 +200,56 @@ function _pantheon_register_upstream_update_notice() {
183200
}
184201
add_action( 'admin_init', '_pantheon_register_upstream_update_notice' );
185202

203+
/**
204+
* AJAX handler: record that the current user dismissed the update notice for the
205+
* current available WordPress version. The version is resolved server-side so the
206+
* client cannot influence which version the dismissal applies to.
207+
*
208+
* @return void
209+
*/
210+
function _pantheon_dismiss_update_notice() {
211+
check_ajax_referer( PANTHEON_UPDATE_NOTICE_DISMISS_ACTION, 'nonce' );
212+
213+
if ( ! is_user_logged_in() ) {
214+
wp_send_json_error( 'not_logged_in', 403 );
215+
}
216+
217+
$available_version = _pantheon_get_latest_wordpress_version();
218+
if ( ! $available_version ) {
219+
wp_send_json_error( 'no_available_version' );
220+
}
221+
222+
update_user_meta( get_current_user_id(), PANTHEON_UPDATE_NOTICE_DISMISSED_META, $available_version );
223+
wp_send_json_success();
224+
}
225+
add_action( 'wp_ajax_' . PANTHEON_UPDATE_NOTICE_DISMISS_ACTION, '_pantheon_dismiss_update_notice' );
226+
227+
/**
228+
* Enqueue the dismiss script and pass it the AJAX URL, action, and nonce.
229+
*
230+
* @return void
231+
*/
232+
function _pantheon_enqueue_update_notice_dismiss() {
233+
wp_enqueue_script(
234+
'pantheon-update-notice-dismiss',
235+
plugin_dir_url( __FILE__ ) . 'assets/js/pantheon-update-notice-dismiss.js',
236+
[],
237+
PANTHEON_MU_PLUGIN_VERSION,
238+
true
239+
);
240+
241+
wp_localize_script(
242+
'pantheon-update-notice-dismiss',
243+
'pantheonUpdateNotice',
244+
[
245+
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
246+
'action' => PANTHEON_UPDATE_NOTICE_DISMISS_ACTION,
247+
'nonce' => wp_create_nonce( PANTHEON_UPDATE_NOTICE_DISMISS_ACTION ),
248+
]
249+
);
250+
}
251+
add_action( 'admin_enqueue_scripts', '_pantheon_enqueue_update_notice_dismiss' );
252+
186253
/**
187254
* Return zero updates and current time as last checked time.
188255
*

tests/e2e/features/hide-update-notice.feature

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,23 @@ Feature: Hide the Pantheon WordPress update notice
3131
When the PANTHEON_SHOW_UPDATE_NOTICE constant is set to false
3232
And I open the WordPress admin page "/wp-admin/update-core.php"
3333
Then the element "#pantheon-update-notice" should be hidden
34+
35+
Scenario: The update notice renders as dismissible
36+
Given a WordPress core update is available
37+
When I open the WordPress admin page "/wp-admin/index.php"
38+
Then the update notice should offer a dismiss option
39+
40+
Scenario: Dismissing the update notice persists across page loads
41+
Given a WordPress core update is available
42+
When I open the WordPress admin page "/wp-admin/index.php"
43+
Then the element "#pantheon-update-notice" should be visible
44+
When I dismiss the update notice
45+
And I open the WordPress admin page "/wp-admin/index.php"
46+
Then the element "#pantheon-update-notice" should be hidden
47+
48+
Scenario: The dismissed notice returns when a newer version is available
49+
Given a WordPress core update is available
50+
And the update notice has been dismissed for the current version
51+
When a newer WordPress core update is released
52+
And I open the WordPress admin page "/wp-admin/index.php"
53+
Then the element "#pantheon-update-notice" should be visible

tests/e2e/lib/pantheon.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,14 @@ function assertSafeName(kind: string, value: string): string {
1212
return value;
1313
}
1414

15+
/** Validate a WordPress username (login/email) before it reaches a shell command. */
16+
export function assertSafeWpUser(value: string): string {
17+
if (!/^[A-Za-z0-9._@-]+$/.test(value)) {
18+
throw new Error(`Unsafe WP_USER: ${JSON.stringify(value)}`);
19+
}
20+
return value;
21+
}
22+
1523
const SITE = assertSafeName('TERMINUS_SITE', process.env.TERMINUS_SITE || 'pantheon-mu-plugin');
1624
const SOURCE_ENV = assertSafeName('TERMINUS_SOURCE_ENV', process.env.TERMINUS_SOURCE_ENV || 'dev');
1725

@@ -91,6 +99,19 @@ if ( get_option( 'e2e_hide_via_filter' ) ) {
9199
if ( get_option( 'e2e_hide_via_constant' ) && ! defined( 'PANTHEON_SHOW_UPDATE_NOTICE' ) ) {
92100
\tdefine( 'PANTHEON_SHOW_UPDATE_NOTICE', false );
93101
}
102+
if ( get_option( 'e2e_force_update_available' ) ) {
103+
\tadd_filter( 'site_transient_update_core', function ( $value ) {
104+
\t\t$forced = get_option( 'e2e_forced_version' );
105+
\t\t$forced = $forced ? $forced : '99.0.0';
106+
\t\treturn (object) [
107+
\t\t\t'updates' => [
108+
\t\t\t\t(object) [ 'current' => $forced, 'response' => 'upgrade', 'locale' => 'en_US' ],
109+
\t\t\t],
110+
\t\t\t'version_checked' => get_bloginfo( 'version' ),
111+
\t\t\t'last_checked' => time(),
112+
\t\t];
113+
\t} );
114+
}
94115
`;
95116

96117
/** SFTP the branch plugin files + the option-toggle shim onto the env. */
@@ -101,6 +122,7 @@ export function installBranchPlugin(env: string): void {
101122
sftpBatch(env, [
102123
`put ${PLUGIN_SRC}/functions.php ${REMOTE_INC}/functions.php`,
103124
`put ${PLUGIN_SRC}/pantheon-updates.php ${REMOTE_INC}/pantheon-updates.php`,
125+
`put ${PLUGIN_SRC}/assets/js/pantheon-update-notice-dismiss.js ${REMOTE_INC}/assets/js/pantheon-update-notice-dismiss.js`,
104126
`put ${shim} ${MU_DIR}/${SHIM_FILENAME}`,
105127
]);
106128
}

tests/e2e/steps/update-notice.steps.ts

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
11
import { createBdd } from 'playwright-bdd';
22
import { test } from 'playwright-bdd';
33
import { expect } from '@playwright/test';
4-
import { readState, wpOption } from '../lib/pantheon';
4+
import { readState, wpOption, wp, assertSafeWpUser } from '../lib/pantheon';
55

66
const { Given, When, Then, After } = createBdd(test);
77

88
// The installed E2E shim activates the real filter/constant based on these WP
99
// options, so scenarios toggle them with a fast DB write (no per-test deploy).
1010
const FILTER_OPTION = 'e2e_hide_via_filter';
1111
const CONSTANT_OPTION = 'e2e_hide_via_constant';
12+
// The shim also forces a core update to appear (and at which version) so the
13+
// dismissible update-available notice renders deterministically.
14+
const FORCE_OPTION = 'e2e_force_update_available';
15+
const VERSION_OPTION = 'e2e_forced_version';
16+
const DISMISSED_META = 'pantheon_dismissed_update_notice';
1217

1318
// The Pantheon sandbox interstitial is bypassed via the Deterrence-Bypass HTTP
1419
// header set in playwright.config.ts (use.extraHTTPHeaders), so no page load
@@ -34,6 +39,11 @@ Then('the element {string} should be hidden', async ({ page }, selector: string)
3439
await expect(page.locator(selector)).toBeHidden();
3540
});
3641

42+
Then('the update notice should offer a dismiss option', async ({ page }) => {
43+
await expect(page.locator('#pantheon-update-notice.is-dismissible')).toBeVisible();
44+
await expect(page.locator('#pantheon-update-notice .notice-dismiss')).toBeVisible();
45+
});
46+
3747
When('I apply the CSS {string}', async ({ page }, css: string) => {
3848
await page.addStyleTag({ content: css });
3949
});
@@ -48,10 +58,47 @@ When('the PANTHEON_SHOW_UPDATE_NOTICE constant is set to false', async () => {
4858
wpOption(multidev, CONSTANT_OPTION, '1');
4959
});
5060

51-
// Reset both toggle options after each scenario so scenarios stay isolated on
52-
// the shared multidev (a DB write, no deploy).
61+
Given('a WordPress core update is available', async () => {
62+
const { multidev } = readState();
63+
wpOption(multidev, FORCE_OPTION, '1');
64+
wpOption(multidev, VERSION_OPTION, '99.0.0');
65+
});
66+
67+
Given('the update notice has been dismissed for the current version', async () => {
68+
const { multidev } = readState();
69+
const user = assertSafeWpUser(process.env.WP_USER ?? '');
70+
wp(multidev, `user meta update ${user} ${DISMISSED_META} 99.0.0`);
71+
});
72+
73+
When('a newer WordPress core update is released', async () => {
74+
const { multidev } = readState();
75+
wpOption(multidev, VERSION_OPTION, '100.0.0');
76+
});
77+
78+
When('I dismiss the update notice', async ({ page }) => {
79+
// WordPress hides the notice client-side on click; wait for our AJAX POST so
80+
// the dismissal is persisted server-side before the next page load.
81+
await Promise.all([
82+
page.waitForResponse(
83+
(r) =>
84+
r.url().includes('admin-ajax.php') &&
85+
(r.request().postData() || '').includes('pantheon_dismiss_update_notice')
86+
),
87+
page.locator('#pantheon-update-notice .notice-dismiss').click(),
88+
]);
89+
});
90+
91+
// Reset toggles + the forced-update state, and clear the per-user dismissal so
92+
// scenarios stay isolated on the shared multidev (DB writes, no deploy).
5393
After(async () => {
5494
const { multidev } = readState();
5595
wpOption(multidev, FILTER_OPTION, '0');
5696
wpOption(multidev, CONSTANT_OPTION, '0');
97+
wpOption(multidev, FORCE_OPTION, '0');
98+
try {
99+
const user = assertSafeWpUser(process.env.WP_USER ?? '');
100+
wp(multidev, `user meta delete ${user} ${DISMISSED_META}`);
101+
} catch {
102+
// Meta may not exist if the scenario never dismissed; ignore.
103+
}
57104
});
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
<?php
2+
/**
3+
* Pantheon update-notice dismiss AJAX handler tests.
4+
*
5+
* @package pantheon
6+
*/
7+
8+
/**
9+
* Tests for the wp_ajax_pantheon_dismiss_update_notice handler.
10+
*
11+
* @group ajax
12+
*/
13+
class Test_Pantheon_Update_Notice_Dismiss extends WP_Ajax_UnitTestCase {
14+
15+
/**
16+
* Simulate that a core update (99.0.0) is available.
17+
*/
18+
private function simulate_core_update_available() {
19+
set_site_transient(
20+
'update_core',
21+
(object) [
22+
'updates' => [
23+
(object) [
24+
'current' => '99.0.0',
25+
'response' => 'upgrade',
26+
'locale' => 'en_us',
27+
],
28+
],
29+
'version_checked' => get_bloginfo( 'version' ),
30+
]
31+
);
32+
}
33+
34+
/**
35+
* A valid dismiss request stores the current available version in user meta.
36+
*/
37+
public function test_dismiss_stores_available_version() {
38+
$this->simulate_core_update_available();
39+
$user_id = self::factory()->user->create( [ 'role' => 'administrator' ] );
40+
wp_set_current_user( $user_id );
41+
42+
$_POST['nonce'] = wp_create_nonce( PANTHEON_UPDATE_NOTICE_DISMISS_ACTION );
43+
44+
try {
45+
$this->_handleAjax( PANTHEON_UPDATE_NOTICE_DISMISS_ACTION );
46+
} catch ( WPAjaxDieContinueException $e ) {
47+
// wp_send_json_success() dies; expected in the success path.
48+
unset( $e );
49+
}
50+
51+
$response = json_decode( $this->_last_response, true );
52+
$this->assertTrue( $response['success'] );
53+
$this->assertEquals( '99.0.0', get_user_meta( $user_id, PANTHEON_UPDATE_NOTICE_DISMISSED_META, true ) );
54+
}
55+
56+
/**
57+
* A request with an invalid nonce is rejected and stores nothing.
58+
*/
59+
public function test_dismiss_rejects_bad_nonce() {
60+
$this->simulate_core_update_available();
61+
$user_id = self::factory()->user->create( [ 'role' => 'administrator' ] );
62+
wp_set_current_user( $user_id );
63+
64+
$_POST['nonce'] = 'invalid-nonce';
65+
66+
try {
67+
$this->expectException( WPAjaxDieStopException::class );
68+
$this->_handleAjax( PANTHEON_UPDATE_NOTICE_DISMISS_ACTION );
69+
} finally {
70+
$this->assertEmpty( get_user_meta( $user_id, PANTHEON_UPDATE_NOTICE_DISMISSED_META, true ) );
71+
}
72+
}
73+
}

0 commit comments

Comments
 (0)