From 639a80c1b01f51a57140c2939a758321f081f4c3 Mon Sep 17 00:00:00 2001 From: Imants Date: Thu, 23 Jul 2026 19:56:09 +0300 Subject: [PATCH 1/4] feat: track unseen Whats New releases --- src/php/Admin/Menus/Welcome_Menu.php | 11 ++ src/php/Admin/Whats_New_Badge.php | 59 ++++++++++ src/php/Core/load.php | 3 + src/php/Plugin.php | 2 + tests/unit/Admin/Menus/Welcome_Menu_Test.php | 56 ++++++++++ tests/unit/Admin/Whats_New_Badge_Test.php | 111 +++++++++++++++++++ tests/unit/Plugin_Whats_New_Badge_Test.php | 60 ++++++++++ 7 files changed, 302 insertions(+) create mode 100644 src/php/Admin/Whats_New_Badge.php create mode 100644 tests/unit/Admin/Menus/Welcome_Menu_Test.php create mode 100644 tests/unit/Admin/Whats_New_Badge_Test.php create mode 100644 tests/unit/Plugin_Whats_New_Badge_Test.php diff --git a/src/php/Admin/Menus/Welcome_Menu.php b/src/php/Admin/Menus/Welcome_Menu.php index 950c95098..d2914bbed 100644 --- a/src/php/Admin/Menus/Welcome_Menu.php +++ b/src/php/Admin/Menus/Welcome_Menu.php @@ -2,6 +2,7 @@ namespace Code_Snippets\Admin\Menus; +use Code_Snippets\Admin\Whats_New_Badge; use Code_Snippets\Client\Welcome_Client; use function Code_Snippets\code_snippets; use const Code_Snippets\PLUGIN_FILE; @@ -42,6 +43,16 @@ public function __construct( $client ) { * * @return void */ + public function load() { + parent::load(); + Whats_New_Badge::mark_seen_release(); + } + + /** + * Render the welcome menu. + * + * @return void + */ public function render() { echo '
'; } diff --git a/src/php/Admin/Whats_New_Badge.php b/src/php/Admin/Whats_New_Badge.php new file mode 100644 index 000000000..4ad03f427 --- /dev/null +++ b/src/php/Admin/Whats_New_Badge.php @@ -0,0 +1,59 @@ +' ); + } + + /** + * Mark the current release as seen. + * + * @return void + */ + public static function mark_seen_release(): void { + $user_id = get_current_user_id(); + + if ( $user_id ) { + update_user_meta( $user_id, self::USER_META_KEY, PLUGIN_VERSION ); + } + } + + /** + * Prevent the indicator from appearing immediately after a fresh install. + * + * @return void + */ + public static function seed_fresh_install(): void { + if ( false === get_option( 'code_snippets_version' ) ) { + self::mark_seen_release(); + } + } +} diff --git a/src/php/Core/load.php b/src/php/Core/load.php index 2a951f7bd..c23293383 100644 --- a/src/php/Core/load.php +++ b/src/php/Core/load.php @@ -7,6 +7,7 @@ namespace Code_Snippets; +use Code_Snippets\Admin\Whats_New_Badge; use Composer\Autoload\ClassLoader; /** @@ -85,4 +86,6 @@ function code_snippets(): Plugin { return $plugin; } +register_activation_hook( PLUGIN_FILE, [ Whats_New_Badge::class, 'seed_fresh_install' ] ); + code_snippets()->load_plugin(); diff --git a/src/php/Plugin.php b/src/php/Plugin.php index 923dc28fb..2f36dfb7a 100644 --- a/src/php/Plugin.php +++ b/src/php/Plugin.php @@ -3,6 +3,7 @@ namespace Code_Snippets; use Code_Snippets\Admin\Bootstrap_Admin; +use Code_Snippets\Admin\Whats_New_Badge; use Code_Snippets\Controller\Cloud_Search_Controller; use Code_Snippets\Core\DB; use Code_Snippets\Core\Licensing; @@ -348,6 +349,7 @@ public function localize_script( string $handle ) { 'isLicensed' => $this->licensing->is_licensed(), 'isCloudConnected' => $this->cloud_connection->is_authenticated(), 'hideUpsell' => Settings\get_setting( 'general', 'hide_upgrade_menu' ), + 'whatsNewUnseen' => Whats_New_Badge::has_unseen_release(), 'snippetView' => Preferences_REST_Controller::get_snippet_view(), 'restAPI' => [ 'base' => esc_url_raw( rest_url() ), diff --git a/tests/unit/Admin/Menus/Welcome_Menu_Test.php b/tests/unit/Admin/Menus/Welcome_Menu_Test.php new file mode 100644 index 000000000..c9279f812 --- /dev/null +++ b/tests/unit/Admin/Menus/Welcome_Menu_Test.php @@ -0,0 +1,56 @@ +user->create( [ 'role' => 'administrator' ] ); + } + + /** + * Loading the page marks the current release seen before assets enqueue. + * + * @return void + */ + public function test_load_marks_current_release_seen(): void { + wp_set_current_user( self::$admin_user_id ); + set_current_screen( 'snippets_page_' . code_snippets()->get_menu_slug( 'welcome' ) ); + update_user_meta( self::$admin_user_id, Whats_New_Badge::USER_META_KEY, '1.0.0' ); + + $client = $this->getMockBuilder( Welcome_Client::class ) + ->disableOriginalConstructor() + ->getMock(); + $menu = new Welcome_Menu( $client ); + $menu->load(); + + $this->assertSame( + PLUGIN_VERSION, + get_user_meta( self::$admin_user_id, Whats_New_Badge::USER_META_KEY, true ) + ); + } +} diff --git a/tests/unit/Admin/Whats_New_Badge_Test.php b/tests/unit/Admin/Whats_New_Badge_Test.php new file mode 100644 index 000000000..424e3036f --- /dev/null +++ b/tests/unit/Admin/Whats_New_Badge_Test.php @@ -0,0 +1,111 @@ +user->create( [ 'role' => 'administrator' ] ); + } + + /** + * Reset the current user's seen version. + * + * @return void + */ + public function set_up() { + parent::set_up(); + wp_set_current_user( self::$admin_user_id ); + delete_user_meta( self::$admin_user_id, Whats_New_Badge::USER_META_KEY ); + } + + /** + * Check whether stored versions identify an unseen release. + * + * @dataProvider provide_seen_versions + * + * @param mixed $seen Stored user-meta value. + * @param bool $expected Whether the current release is unseen. + * + * @return void + */ + public function test_has_unseen_release( $seen, bool $expected ): void { + if ( null !== $seen ) { + update_user_meta( self::$admin_user_id, Whats_New_Badge::USER_META_KEY, $seen ); + } + + $this->assertSame( $expected, Whats_New_Badge::has_unseen_release() ); + } + + /** + * Provide stored seen versions and their expected state. + * + * @return array + */ + public static function provide_seen_versions(): array { + return [ + 'missing' => [ null, true ], + 'older' => [ '1.0.0', true ], + 'current' => [ PLUGIN_VERSION, false ], + 'newer' => [ '99.0.0', false ], + 'malformed' => [ [ 'unexpected' ], true ], + ]; + } + + /** + * Marking the page seen stores the current release for this user. + * + * @return void + */ + public function test_mark_seen_release(): void { + Whats_New_Badge::mark_seen_release(); + + $this->assertSame( + PLUGIN_VERSION, + get_user_meta( self::$admin_user_id, Whats_New_Badge::USER_META_KEY, true ) + ); + } + + /** + * Activation seeds fresh installs without overwriting reactivations. + * + * @return void + */ + public function test_seed_fresh_install(): void { + delete_option( 'code_snippets_version' ); + Whats_New_Badge::seed_fresh_install(); + $this->assertSame( + PLUGIN_VERSION, + get_user_meta( self::$admin_user_id, Whats_New_Badge::USER_META_KEY, true ) + ); + + update_user_meta( self::$admin_user_id, Whats_New_Badge::USER_META_KEY, '1.0.0' ); + update_option( 'code_snippets_version', PLUGIN_VERSION ); + Whats_New_Badge::seed_fresh_install(); + $this->assertSame( + '1.0.0', + get_user_meta( self::$admin_user_id, Whats_New_Badge::USER_META_KEY, true ) + ); + } +} diff --git a/tests/unit/Plugin_Whats_New_Badge_Test.php b/tests/unit/Plugin_Whats_New_Badge_Test.php new file mode 100644 index 000000000..19cc64e95 --- /dev/null +++ b/tests/unit/Plugin_Whats_New_Badge_Test.php @@ -0,0 +1,60 @@ +user->create( [ 'role' => 'administrator' ] ); + } + + /** + * The shared script bootstrap exposes the unseen flag. + * + * @return void + */ + public function test_localized_bootstrap_flag(): void { + wp_set_current_user( self::$admin_user_id ); + update_user_meta( self::$admin_user_id, Whats_New_Badge::USER_META_KEY, '1.0.0' ); + $this->assertStringContainsString( '"whatsNewUnseen":"1"', $this->get_localized_data() ); + + update_user_meta( self::$admin_user_id, Whats_New_Badge::USER_META_KEY, PLUGIN_VERSION ); + $this->assertStringContainsString( '"whatsNewUnseen":""', $this->get_localized_data() ); + } + + /** + * Retrieve the localized script data. + * + * @return string + */ + private function get_localized_data(): string { + $handle = wp_unique_id( 'whats-new-badge-' ); + wp_register_script( $handle, '', [], PLUGIN_VERSION, true ); + code_snippets()->localize_script( $handle ); + $data = wp_scripts()->get_data( $handle, 'data' ); + + $this->assertIsString( $data ); + return $data; + } +} From 5cbcaf180b6e0f08f14bc2d9edc02e52afb0b05f Mon Sep 17 00:00:00 2001 From: Imants Date: Thu, 23 Jul 2026 19:56:28 +0300 Subject: [PATCH 2/4] feat: show unseen Whats New nav dot --- src/css/common/_toolbar.scss | 11 ++++++++ src/js/components/common/Toolbar.tsx | 11 +++++++- src/js/types/Window.ts | 1 + tests/e2e/code-snippets-whats-new.spec.ts | 33 +++++++++++++++++++++++ 4 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 tests/e2e/code-snippets-whats-new.spec.ts diff --git a/src/css/common/_toolbar.scss b/src/css/common/_toolbar.scss index 2fe2b16d2..b213bcfbe 100644 --- a/src/css/common/_toolbar.scss +++ b/src/css/common/_toolbar.scss @@ -62,6 +62,17 @@ $wpcontent-inline-start-indent: 20px; a { color: inherit; text-decoration: none; + position: relative; + } + + .nav-dot { + position: absolute; + inset-block-start: -5px; + inset-inline-end: -10px; + inline-size: 8px; + block-size: 8px; + border-radius: 50%; + background: #d63638; } } diff --git a/src/js/components/common/Toolbar.tsx b/src/js/components/common/Toolbar.tsx index eac1b4c61..cf0e6563d 100644 --- a/src/js/components/common/Toolbar.tsx +++ b/src/js/components/common/Toolbar.tsx @@ -19,6 +19,7 @@ interface NavLink { pageSlug?: string subpage?: typeof SUBPAGES[number] end?: boolean + whatsNewUnseen?: boolean } const UPPER_NAV_LINKS: readonly NavLink[] = [ @@ -38,7 +39,8 @@ const UPPER_NAV_LINKS: readonly NavLink[] = [ name: 'welcome', url: window.CODE_SNIPPETS?.urls.welcome, label: __("What's New", 'code-snippets'), - pageSlug: 'code-snippets-welcome' + pageSlug: 'code-snippets-welcome', + whatsNewUnseen: window.CODE_SNIPPETS?.whatsNewUnseen } ] @@ -106,6 +108,13 @@ const UpperNav: React.FC = ({ setIsUpsellDialogOpen }) => {...link.external && { target: '_blank', rel: 'noopener noreferrer' }} > {link.label} + {link.whatsNewUnseen && ( + + + {__('New content available', 'code-snippets')} + + + )} )} diff --git a/src/js/types/Window.ts b/src/js/types/Window.ts index 2c0eb5986..9f1f8e5af 100644 --- a/src/js/types/Window.ts +++ b/src/js/types/Window.ts @@ -25,6 +25,7 @@ declare global { isLicensed: boolean hideUpsell: boolean isCloudConnected: boolean + whatsNewUnseen: boolean snippetView: SnippetView restAPI: { base: string diff --git a/tests/e2e/code-snippets-whats-new.spec.ts b/tests/e2e/code-snippets-whats-new.spec.ts new file mode 100644 index 000000000..ebdff562f --- /dev/null +++ b/tests/e2e/code-snippets-whats-new.spec.ts @@ -0,0 +1,33 @@ +import { expect, test } from '@playwright/test' +import { URLS } from './helpers/constants' +import { wpCli } from './helpers/wpCli' + +const WHATS_NEW_SEEN_META_KEY = 'code_snippets_whats_new_seen_version' + +test.describe("What's New unseen release indicator", () => { + test.afterEach(async () => { + await wpCli(['user', 'meta', 'delete', 'admin', WHATS_NEW_SEEN_META_KEY]) + }) + + test('Dot clears after opening the page and stays cleared', async ({ page }) => { + await wpCli(['user', 'meta', 'update', 'admin', WHATS_NEW_SEEN_META_KEY, '1.0.0']) + await page.goto(URLS.SNIPPETS_ADMIN) + + const upperNav = page.locator('.code-snippets-toolbar-upper') + const whatsNewLink = upperNav.getByRole('link', { name: /What's New/ }) + await expect(whatsNewLink.locator('.nav-dot')).toBeVisible() + await expect(whatsNewLink).toContainText('New content available') + + await whatsNewLink.click() + await expect(page).toHaveURL(URLS.WELCOME_SCREEN_ADMIN) + await expect(upperNav.getByRole('link', { name: /What's New/ }).locator('.nav-dot')).toBeHidden() + + const currentVersion = (await wpCli(['eval', 'echo CODE_SNIPPETS_VERSION;'])).trim() + const seenVersion = + (await wpCli(['user', 'meta', 'get', 'admin', WHATS_NEW_SEEN_META_KEY])).trim() + expect(seenVersion).toBe(currentVersion) + + await page.goto(URLS.SNIPPETS_ADMIN) + await expect(upperNav.getByRole('link', { name: /What's New/ }).locator('.nav-dot')).toBeHidden() + }) +}) From eace30a14f356230e15e1cd4e05744d31f9726c8 Mon Sep 17 00:00:00 2001 From: Imants Date: Thu, 23 Jul 2026 22:08:07 +0300 Subject: [PATCH 3/4] chore: name Whats New dot explicitly --- src/css/common/_toolbar.scss | 2 +- src/js/components/common/Toolbar.tsx | 2 +- tests/e2e/code-snippets-whats-new.spec.ts | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/css/common/_toolbar.scss b/src/css/common/_toolbar.scss index b213bcfbe..723cdd56c 100644 --- a/src/css/common/_toolbar.scss +++ b/src/css/common/_toolbar.scss @@ -65,7 +65,7 @@ $wpcontent-inline-start-indent: 20px; position: relative; } - .nav-dot { + .whats-new-dot { position: absolute; inset-block-start: -5px; inset-inline-end: -10px; diff --git a/src/js/components/common/Toolbar.tsx b/src/js/components/common/Toolbar.tsx index cf0e6563d..b965127b2 100644 --- a/src/js/components/common/Toolbar.tsx +++ b/src/js/components/common/Toolbar.tsx @@ -109,7 +109,7 @@ const UpperNav: React.FC = ({ setIsUpsellDialogOpen }) => > {link.label} {link.whatsNewUnseen && ( - + {__('New content available', 'code-snippets')} diff --git a/tests/e2e/code-snippets-whats-new.spec.ts b/tests/e2e/code-snippets-whats-new.spec.ts index ebdff562f..5f0a12114 100644 --- a/tests/e2e/code-snippets-whats-new.spec.ts +++ b/tests/e2e/code-snippets-whats-new.spec.ts @@ -15,12 +15,12 @@ test.describe("What's New unseen release indicator", () => { const upperNav = page.locator('.code-snippets-toolbar-upper') const whatsNewLink = upperNav.getByRole('link', { name: /What's New/ }) - await expect(whatsNewLink.locator('.nav-dot')).toBeVisible() + await expect(whatsNewLink.locator('.whats-new-dot')).toBeVisible() await expect(whatsNewLink).toContainText('New content available') await whatsNewLink.click() await expect(page).toHaveURL(URLS.WELCOME_SCREEN_ADMIN) - await expect(upperNav.getByRole('link', { name: /What's New/ }).locator('.nav-dot')).toBeHidden() + await expect(upperNav.getByRole('link', { name: /What's New/ }).locator('.whats-new-dot')).toBeHidden() const currentVersion = (await wpCli(['eval', 'echo CODE_SNIPPETS_VERSION;'])).trim() const seenVersion = @@ -28,6 +28,6 @@ test.describe("What's New unseen release indicator", () => { expect(seenVersion).toBe(currentVersion) await page.goto(URLS.SNIPPETS_ADMIN) - await expect(upperNav.getByRole('link', { name: /What's New/ }).locator('.nav-dot')).toBeHidden() + await expect(upperNav.getByRole('link', { name: /What's New/ }).locator('.whats-new-dot')).toBeHidden() }) }) From 86065304873ab8cb40e363211cf171216f13e646 Mon Sep 17 00:00:00 2001 From: Imants Date: Thu, 23 Jul 2026 22:08:18 +0300 Subject: [PATCH 4/4] chore: cover malformed Whats New metadata --- tests/unit/Admin/Whats_New_Badge_Test.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/unit/Admin/Whats_New_Badge_Test.php b/tests/unit/Admin/Whats_New_Badge_Test.php index 424e3036f..8e052f9b4 100644 --- a/tests/unit/Admin/Whats_New_Badge_Test.php +++ b/tests/unit/Admin/Whats_New_Badge_Test.php @@ -69,7 +69,8 @@ public static function provide_seen_versions(): array { 'older' => [ '1.0.0', true ], 'current' => [ PLUGIN_VERSION, false ], 'newer' => [ '99.0.0', false ], - 'malformed' => [ [ 'unexpected' ], true ], + 'malformed array' => [ [ 'unexpected' ], true ], + 'malformed string' => [ 'garbage', true ], ]; }