diff --git a/projects/plugins/jetpack/changelog/add-self-hosted-stats-admin-bar-link b/projects/plugins/jetpack/changelog/add-self-hosted-stats-admin-bar-link
new file mode 100644
index 000000000000..b31aa3238087
--- /dev/null
+++ b/projects/plugins/jetpack/changelog/add-self-hosted-stats-admin-bar-link
@@ -0,0 +1,4 @@
+Significance: patch
+Type: enhancement
+
+Stats: Add a link to the site-name admin bar menu.
diff --git a/projects/plugins/jetpack/modules/stats.php b/projects/plugins/jetpack/modules/stats.php
index 8bb669f56a58..3a85872bbdbb 100644
--- a/projects/plugins/jetpack/modules/stats.php
+++ b/projects/plugins/jetpack/modules/stats.php
@@ -65,6 +65,7 @@ function stats_load() {
Admin_Post_List_Column::register();
add_action( 'jetpack_admin_menu', 'stats_admin_menu' );
+ add_action( 'wp_before_admin_bar_render', 'stats_add_link_to_admin_bar_site_menu' );
add_filter( 'pre_option_db_version', 'stats_ignore_db_version' );
@@ -824,6 +825,34 @@ function stats_admin_bar_menu( &$wp_admin_bar ) {
$wp_admin_bar->add_menu( $menu );
}
+/**
+ * Adds a Stats link to the site-name admin bar submenu, alongside Dashboard.
+ *
+ * @access public
+ * @return void
+ */
+function stats_add_link_to_admin_bar_site_menu() {
+ global $wp_admin_bar;
+
+ if (
+ ! is_object( $wp_admin_bar ) ||
+ ! $wp_admin_bar->get_node( 'dashboard' ) ||
+ ! current_user_can( 'view_stats' ) ||
+ ( new Host() )->is_wpcom_platform()
+ ) {
+ return;
+ }
+
+ $wp_admin_bar->add_node(
+ array(
+ 'parent' => 'site-name',
+ 'id' => 'jetpack-stats',
+ 'title' => __( 'Stats', 'jetpack' ),
+ 'href' => admin_url( 'admin.php?page=stats' ),
+ )
+ );
+}
+
/**
* Stats Get Blog.
*
diff --git a/projects/plugins/jetpack/phpunit.11.xml.dist b/projects/plugins/jetpack/phpunit.11.xml.dist
index d120f83487f5..e47501f4bde7 100644
--- a/projects/plugins/jetpack/phpunit.11.xml.dist
+++ b/projects/plugins/jetpack/phpunit.11.xml.dist
@@ -78,6 +78,9 @@
tests/php/modules/sitemaps
+
+ tests/php/modules/stats
+
tests/php/modules/subscriptions
diff --git a/projects/plugins/jetpack/phpunit.9.xml.dist b/projects/plugins/jetpack/phpunit.9.xml.dist
index 27ee1df85009..3e47f09dd0e1 100644
--- a/projects/plugins/jetpack/phpunit.9.xml.dist
+++ b/projects/plugins/jetpack/phpunit.9.xml.dist
@@ -69,6 +69,9 @@
tests/php/modules/sitemaps
+
+ tests/php/modules/stats
+
tests/php/modules/subscriptions
diff --git a/projects/plugins/jetpack/tests/php/modules/stats/Stats_Admin_Bar_Test.php b/projects/plugins/jetpack/tests/php/modules/stats/Stats_Admin_Bar_Test.php
new file mode 100644
index 000000000000..93222e60ce58
--- /dev/null
+++ b/projects/plugins/jetpack/tests/php/modules/stats/Stats_Admin_Bar_Test.php
@@ -0,0 +1,177 @@
+user->create(
+ array(
+ 'role' => 'administrator',
+ )
+ );
+ wp_set_current_user( $user_id );
+
+ add_action( 'wp_before_admin_bar_render', 'stats_add_link_to_admin_bar_site_menu' );
+ }
+
+ /**
+ * Tear down the test environment.
+ */
+ public function tear_down() {
+ self::reset_admin_bar_global();
+ remove_action( 'wp_before_admin_bar_render', 'stats_add_link_to_admin_bar_site_menu' );
+ remove_filter( 'user_has_cap', array( $this, 'grant_view_stats' ) );
+ remove_role( 'jetpack_no_stats' );
+ Constants::clear_constants();
+ Status_Cache::clear();
+ wp_set_current_user( 0 );
+
+ parent::tear_down();
+ }
+
+ /**
+ * Forces current_user_can( 'view_stats' ) to true for the test.
+ *
+ * @param array $allcaps All capabilities of the user.
+ * @return array
+ */
+ public function grant_view_stats( $allcaps ) {
+ $allcaps['view_stats'] = true;
+ return $allcaps;
+ }
+
+ /**
+ * Tests that the Stats link is added when the user can view Stats.
+ */
+ public function test_stats_link_shown_when_user_can_view_stats() {
+ add_filter( 'user_has_cap', array( $this, 'grant_view_stats' ) );
+ $admin_bar = self::make_test_admin_bar_with_dashboard();
+
+ do_action( 'wp_before_admin_bar_render' );
+
+ $stats_node = $admin_bar->get_node( 'jetpack-stats' );
+
+ $this->assertNotNull( $stats_node );
+ $this->assertSame( 'site-name', $stats_node->parent );
+ $this->assertSame( 'Stats', $stats_node->title );
+ $this->assertSame( admin_url( 'admin.php?page=stats' ), $stats_node->href );
+ }
+
+ /**
+ * Tests that the Stats link is hidden when the user cannot view Stats.
+ */
+ public function test_stats_link_hidden_when_user_cannot_view_stats() {
+ add_role( 'jetpack_no_stats', 'No Stats', array( 'read' => true ) );
+ $user_id = self::factory()->user->create(
+ array(
+ 'role' => 'jetpack_no_stats',
+ )
+ );
+ wp_set_current_user( $user_id );
+
+ $admin_bar = self::make_test_admin_bar_with_dashboard();
+
+ do_action( 'wp_before_admin_bar_render' );
+
+ $stats_node = $admin_bar->get_node( 'jetpack-stats' );
+
+ $this->assertFalse( current_user_can( 'view_stats' ) );
+ $this->assertNull( $stats_node );
+ }
+
+ /**
+ * Tests that the Stats link is hidden when core's Dashboard node is absent.
+ */
+ public function test_stats_link_hidden_when_dashboard_node_absent() {
+ add_filter( 'user_has_cap', array( $this, 'grant_view_stats' ) );
+ $admin_bar = self::make_test_admin_bar();
+
+ do_action( 'wp_before_admin_bar_render' );
+
+ $stats_node = $admin_bar->get_node( 'jetpack-stats' );
+
+ $this->assertNull( $stats_node );
+ }
+
+ /**
+ * Tests that the Stats link is hidden on WordPress.com platform sites.
+ */
+ public function test_stats_link_hidden_on_wpcom_platform() {
+ add_filter( 'user_has_cap', array( $this, 'grant_view_stats' ) );
+ Constants::set_constant( 'IS_WPCOM', true );
+ $admin_bar = self::make_test_admin_bar_with_dashboard();
+
+ do_action( 'wp_before_admin_bar_render' );
+
+ $stats_node = $admin_bar->get_node( 'jetpack-stats' );
+
+ $this->assertNull( $stats_node );
+ }
+
+ /**
+ * Builds a test admin bar with the core site-name node.
+ *
+ * @return WP_Admin_Bar
+ */
+ private static function make_test_admin_bar() {
+ global $wp_admin_bar;
+
+ $wp_admin_bar = new WP_Admin_Bar();
+ $wp_admin_bar->add_node(
+ array(
+ 'id' => 'site-name',
+ 'title' => 'Test Site',
+ 'href' => admin_url(),
+ )
+ );
+
+ return $wp_admin_bar;
+ }
+
+ /**
+ * Builds a test admin bar with core's site-name and dashboard nodes.
+ *
+ * @return WP_Admin_Bar
+ */
+ private static function make_test_admin_bar_with_dashboard() {
+ $admin_bar = self::make_test_admin_bar();
+ $admin_bar->add_node(
+ array(
+ 'parent' => 'site-name',
+ 'id' => 'dashboard',
+ 'title' => 'Dashboard',
+ 'href' => admin_url(),
+ )
+ );
+
+ return $admin_bar;
+ }
+
+ /**
+ * Resets the global admin bar object.
+ */
+ private static function reset_admin_bar_global() {
+ global $wp_admin_bar;
+
+ $wp_admin_bar = null;
+ }
+}