From 59822cc6bfa8e58e28c672ddefdd3913841fd1a1 Mon Sep 17 00:00:00 2001 From: David Stone Date: Thu, 22 Oct 2026 14:58:35 -0600 Subject: [PATCH 1/2] feat(sites): lazy-load My Sites toolbar --- assets/js/my-sites-toolbar.js | 225 +++++++++++++ inc/managers/class-site-manager.php | 298 +++++++++++++++++- .../WP_Ultimo/Managers/Site_Manager_Test.php | 66 ++-- 3 files changed, 564 insertions(+), 25 deletions(-) create mode 100644 assets/js/my-sites-toolbar.js diff --git a/assets/js/my-sites-toolbar.js b/assets/js/my-sites-toolbar.js new file mode 100644 index 000000000..50ce256bc --- /dev/null +++ b/assets/js/my-sites-toolbar.js @@ -0,0 +1,225 @@ +(function (window, document) { + + 'use strict'; + + const config = window.wu_my_sites_toolbar || {}; + const labels = config.labels || {}; + + function createMenuItem(id, label, url) { + + const item = document.createElement('li'); + const link = document.createElement('a'); + + item.id = 'wp-admin-bar-' + id; + item.setAttribute('role', 'group'); + link.className = 'ab-item'; + link.href = url; + link.setAttribute('role', 'menuitem'); + link.textContent = label; + item.appendChild(link); + + return item; + } + + function createSiteItem(site) { + + const item = createMenuItem('blog-' + site.id, site.name, site.url); + const menu = document.createElement('ul'); + const actions = [ + [ 'dashboard', site.dashboard_url ], + [ 'new_post', site.new_post_url ], + [ 'comments', site.comments_url ], + [ 'visit', site.visit_url ], + ]; + + item.classList.add('menupop'); + item.querySelector('.ab-item').setAttribute('aria-expanded', 'false'); + menu.className = 'ab-submenu'; + menu.setAttribute('role', 'menu'); + + actions.forEach(function (action) { + + if (action[ 1 ]) { + menu.appendChild(createMenuItem('blog-' + site.id + '-' + action[ 0 ], labels[ action[ 0 ] ], action[ 1 ])); + } + }); + + if (menu.children.length) { + const wrapper = document.createElement('div'); + + wrapper.className = 'ab-sub-wrapper'; + wrapper.appendChild(menu); + item.appendChild(wrapper); + } + + return item; + } + + function init() { + + const root = document.getElementById('wp-admin-bar-my-sites'); + const list = document.getElementById('wp-admin-bar-my-sites-list'); + const allSites = document.getElementById('wp-admin-bar-wu-my-sites-toolbar-all'); + + if (! root || ! list || ! allSites || ! config.ajax_url || ! config.nonce) { + return; + } + + const trigger = root.querySelector(':scope > .ab-item'); + const status = document.createElement('li'); + const state = { + loading: false, + loaded: false, + nextPage: 1, + }; + + status.id = 'wp-admin-bar-wu-my-sites-toolbar-status'; + status.setAttribute('role', 'status'); + status.setAttribute('aria-live', 'polite'); + list.insertBefore(status, allSites); + + function setStatus(message, retry) { + + status.textContent = ''; + + if (! message) { + return; + } + + if (! retry) { + status.textContent = message; + return; + } + + const link = document.createElement('a'); + + link.className = 'ab-item'; + link.href = '#'; + link.setAttribute('role', 'menuitem'); + link.textContent = message + ' ' + (labels.retry || 'Retry'); + link.addEventListener('click', function (event) { + + event.preventDefault(); + load(state.nextPage); + }); + status.appendChild(link); + } + + function addLoadMore() { + + const existing = document.getElementById('wp-admin-bar-wu-my-sites-toolbar-more'); + + if (existing || ! state.nextPage) { + return; + } + + const more = createMenuItem('wu-my-sites-toolbar-more', labels.load_more || 'Load more sites', '#'); + + more.querySelector('.ab-item').addEventListener('click', function (event) { + + event.preventDefault(); + more.remove(); + load(state.nextPage); + }); + list.insertBefore(more, allSites); + } + + function load(page) { + + if (state.loading || ! page) { + return; + } + + state.loading = true; + setStatus(labels.loading || 'Loading sites…'); + + const request = new window.URLSearchParams({ + action: config.action, + nonce: config.nonce, + page, + }); + + window.fetch(config.ajax_url, { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', + }, + body: request.toString(), + }).then(function (response) { + + if (! response.ok) { + throw new Error('request_failed'); + } + + return response.json(); + + }).then(function (response) { + + if (! response || ! response.success || ! response.data) { + throw new Error('invalid_response'); + } + + const data = response.data; + + (data.sites || []).forEach(function (site) { + list.insertBefore(createSiteItem(site), allSites); + }); + + state.loaded = true; + state.nextPage = data.next_page || 0; + setStatus((data.sites || []).length ? '' : (labels.empty || 'No additional sites are available.')); + addLoadMore(); + + }).catch(function () { + + setStatus(labels.failed || 'Sites could not be loaded.', true); + + }).finally(function () { + + state.loading = false; + + }); + } + + function loadInitialPage() { + + if (! state.loaded) { + load(1); + } + } + + root.addEventListener('mouseenter', loadInitialPage); + root.addEventListener('focusin', loadInitialPage); + root.addEventListener('pointerdown', function (event) { + + if ('touch' === event.pointerType) { + loadInitialPage(); + } + }); + + if (trigger) { + trigger.addEventListener('keydown', function (event) { + + if ('Enter' === event.key || ' ' === event.key || 'ArrowDown' === event.key) { + loadInitialPage(); + } + }); + } + + root.addEventListener('keydown', function (event) { + + if ('Escape' === event.key && trigger && root.contains(root.ownerDocument.activeElement)) { + event.preventDefault(); + trigger.focus(); + } + }); + } + + if ('loading' === document.readyState) { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } + +}(window, document)); diff --git a/inc/managers/class-site-manager.php b/inc/managers/class-site-manager.php index 46813f6a4..69b3bd741 100644 --- a/inc/managers/class-site-manager.php +++ b/inc/managers/class-site-manager.php @@ -106,6 +106,14 @@ public function init(): void { add_action('pre_get_blogs_of_user', [$this, 'hide_customer_sites_from_super_admin_list'], 999, 3); + add_action('admin_bar_menu', [$this, 'add_lazy_my_sites_toolbar_controls'], 100); + + add_action('wp_enqueue_scripts', [$this, 'enqueue_my_sites_toolbar_script']); + + add_action('admin_enqueue_scripts', [$this, 'enqueue_my_sites_toolbar_script']); + + add_action('wp_ajax_wu_get_my_sites_toolbar_page', [$this, 'get_my_sites_toolbar_page']); + add_filter('wpmu_validate_blog_signup', [$this, 'allow_hyphens_in_site_name'], 10, 1); add_action('wu_daily', [$this, 'delete_pending_sites']); @@ -1278,11 +1286,11 @@ public function hide_customer_sites_from_super_admin_list($sites, $user_id, $all } /** - * Determines whether get_blogs_of_user is preparing the front-end My Sites toolbar. + * Determines whether get_blogs_of_user is preparing the My Sites toolbar. * - * The pre_get_blogs_of_user filter is also used by wp-admin, REST requests, and - * application code. Checking that WP_Admin_Bar::initialize() directly calls - * get_blogs_of_user() keeps the optimization away from nested calls such as + * The pre_get_blogs_of_user filter is also used by REST requests and application + * code. Checking that WP_Admin_Bar::initialize() directly calls get_blogs_of_user() + * keeps the optimization away from nested calls such as * get_active_blog_for_user(), which may update the user's primary site. * * @since 2.15.0 @@ -1292,9 +1300,7 @@ public function hide_customer_sites_from_super_admin_list($sites, $user_id, $all protected function is_frontend_my_sites_toolbar_request($backtrace = null) { if ( - ! wu_get_setting('optimize_frontend_my_sites_toolbar', false) - || is_admin() - || wp_doing_ajax() + wp_doing_ajax() || wp_doing_cron() || (defined('REST_REQUEST') && REST_REQUEST) || (defined('WP_CLI') && WP_CLI) @@ -1325,6 +1331,284 @@ protected function is_frontend_my_sites_toolbar_request($backtrace = null) { return false; } + /** + * Adds the progressive-enhancement controls after WordPress adds My Sites. + * + * WordPress receives only the current site during direct toolbar initialization. + * This retains its current-site navigation while the controller fetches bounded + * pages only after the user opens the menu. + * + * @since 2.17.2 + * @param \WP_Admin_Bar $wp_admin_bar The admin bar instance. + * @return void + */ + public function add_lazy_my_sites_toolbar_controls(\WP_Admin_Bar $wp_admin_bar): void { + + $my_sites = $wp_admin_bar->get_node('my-sites'); + $my_sites_list = $wp_admin_bar->get_node('my-sites-list'); + + if ( ! $my_sites || ! $my_sites_list) { + return; + } + + $wp_admin_bar->add_node( + [ + 'parent' => 'my-sites-list', + 'id' => 'wu-my-sites-toolbar-all', + 'title' => esc_html__('All My Sites', 'ultimate-multisite'), + 'href' => $my_sites->href, + 'meta' => [ + 'class' => 'wu-my-sites-toolbar-all', + ], + ] + ); + } + + /** + * Enqueue the controller only on requests which render an authenticated toolbar. + * + * @since 2.17.2 + * @return void + */ + public function enqueue_my_sites_toolbar_script(): void { + + if ( ! is_multisite() || ! is_user_logged_in() || ! is_admin_bar_showing()) { + return; + } + + wp_enqueue_script( + 'wu-my-sites-toolbar', + wu_get_asset('my-sites-toolbar.js', 'js'), + [], + \WP_Ultimo::VERSION, + true + ); + + wp_localize_script( + 'wu-my-sites-toolbar', + 'wu_my_sites_toolbar', + [ + 'ajax_url' => admin_url('admin-ajax.php'), + 'action' => 'wu_get_my_sites_toolbar_page', + 'nonce' => wp_create_nonce('wu_my_sites_toolbar'), + 'labels' => [ + 'dashboard' => __('Dashboard', 'ultimate-multisite'), + 'new_post' => get_post_type_object('post')->labels->new_item, + 'comments' => __('Manage Comments', 'ultimate-multisite'), + 'visit' => __('Visit Site', 'ultimate-multisite'), + 'loading' => __('Loading sites…', 'ultimate-multisite'), + 'empty' => __('No additional sites are available.', 'ultimate-multisite'), + 'load_more' => __('Load more sites', 'ultimate-multisite'), + 'retry' => __('Retry loading sites', 'ultimate-multisite'), + 'failed' => __('Sites could not be loaded.', 'ultimate-multisite'), + ], + ] + ); + } + + /** + * Returns a bounded, authorized page of My Sites toolbar entries. + * + * @since 2.17.2 + * @return void + */ + public function get_my_sites_toolbar_page(): void { + + if ( ! is_multisite() || ! is_user_logged_in()) { + wp_send_json_error(new \WP_Error('unauthorized', __('You do not have permission to perform this action.', 'ultimate-multisite')), 403); + } + + if ( ! check_ajax_referer('wu_my_sites_toolbar', 'nonce', false)) { + wp_send_json_error(new \WP_Error('invalid_nonce', __('Security check failed. Please try again.', 'ultimate-multisite')), 403); + } + + $page = max(1, absint(wu_request('page', 1))); + + wp_send_json_success($this->get_my_sites_toolbar_page_data(get_current_user_id(), $page)); + } + + /** + * Gets a bounded page of sites and the toolbar links allowed for each site. + * + * @since 2.17.2 + * @param int $user_id The authenticated user ID. + * @param int $page The one-based page number. + * @return array{sites: array>, has_more: bool, next_page: int} + */ + protected function get_my_sites_toolbar_page_data($user_id, $page) { + + $per_page = 20; + $site_ids = array_diff($this->get_my_sites_toolbar_site_ids($user_id), [get_current_blog_id()]); + + if (empty($site_ids)) { + return [ + 'sites' => [], + 'has_more' => false, + 'next_page' => 0, + ]; + } + + $args = [ + 'site__in' => $site_ids, + 'number' => $per_page + 1, + 'offset' => ($page - 1) * $per_page, + 'orderby' => 'id', + 'order' => 'ASC', + 'update_site_meta_cache' => false, + 'archived' => 0, + 'spam' => 0, + 'deleted' => 0, + ]; + + if (is_super_admin($user_id)) { + $args['meta_query'] = [ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query + 'relation' => 'OR', + [ + 'key' => 'wu_type', + 'compare' => 'NOT EXISTS', + ], + [ + 'key' => 'wu_type', + 'compare' => 'NOT LIKE', + 'value' => Site_Type::CUSTOMER_OWNED, + ], + ]; + } + + $queried_sites = get_sites($args); + $has_more = $per_page < count($queried_sites); + $sites = []; + + foreach (array_slice($queried_sites, 0, $per_page) as $site) { + $sites[ $site->id ] = (object) [ + 'userblog_id' => $site->id, + 'blogname' => $site->blogname, + 'domain' => $site->domain, + 'path' => $site->path, + 'site_id' => $site->network_id, + 'siteurl' => $site->siteurl, + 'archived' => $site->archived, + 'mature' => $site->mature, + 'spam' => $site->spam, + 'deleted' => $site->deleted, + ]; + } + + $sites = apply_filters('get_blogs_of_user', $sites, $user_id, false); // phpcs:ignore + $items = []; + + foreach ($sites as $site) { + if ( ! is_object($site) || empty($site->userblog_id)) { + continue; + } + + $item = $this->get_my_sites_toolbar_item((int) $site->userblog_id); + + if ($item) { + $items[] = $item; + } + } + + return [ + 'sites' => $items, + 'has_more' => $has_more, + 'next_page' => $has_more ? $page + 1 : 0, + ]; + } + + /** + * Extracts the site IDs from the user's multisite capability metadata. + * + * @since 2.17.2 + * @param int $user_id User ID. + * @return int[] + */ + protected function get_my_sites_toolbar_site_ids($user_id) { + + global $wpdb; + + $site_ids = []; + $keys = get_user_meta($user_id); + + foreach (array_keys($keys) as $key) { + if ($wpdb->base_prefix . 'capabilities' === $key && defined('MULTISITE')) { + $site_ids[] = wu_get_main_site_id(); + + continue; + } + + if ( + ! str_ends_with($key, 'capabilities') + || ($wpdb->base_prefix && ! str_starts_with($key, (string) $wpdb->base_prefix)) + ) { + continue; + } + + $site_id = str_replace([$wpdb->base_prefix, '_capabilities'], '', $key); + + if (is_numeric($site_id)) { + $site_ids[] = (int) $site_id; + } + } + + return array_values(array_unique($site_ids)); + } + + /** + * Builds the links which WordPress normally creates after switching to a site. + * + * @since 2.17.2 + * @param int $site_id Site ID. + * @return array|false + */ + protected function get_my_sites_toolbar_item($site_id) { + + $site = get_site($site_id); + + if ( ! $site || $site->archived || $site->spam || $site->deleted) { + return false; + } + + switch_to_blog($site_id); + + try { + $can_read = current_user_can('read'); + $blogname = get_bloginfo('name'); + + if ( ! $blogname) { + $blogname = preg_replace('#^(https?://)?(www\\.)?#', '', get_home_url()); + } + + $item = [ + 'id' => $site_id, + 'name' => $blogname, + 'url' => $can_read ? admin_url() : home_url(), + 'dashboard_url' => '', + 'new_post_url' => '', + 'comments_url' => '', + 'visit_url' => home_url('/'), + ]; + + if ($can_read) { + $item['dashboard_url'] = \WP_Ultimo\SSO\Admin_Bar_Magic_Links::get_instance()->get_admin_bar_action_url($site_id); + } + + $post_type = get_post_type_object('post'); + + if ($post_type && current_user_can($post_type->cap->create_posts)) { + $item['new_post_url'] = admin_url('post-new.php'); + } + + if (current_user_can('edit_posts')) { + $item['comments_url'] = admin_url('edit-comments.php'); + } + + return $item; + } finally { + restore_current_blog(); + } + } + /** * Returns the current site in the format expected by the My Sites toolbar. * diff --git a/tests/WP_Ultimo/Managers/Site_Manager_Test.php b/tests/WP_Ultimo/Managers/Site_Manager_Test.php index e854aeb1d..1846934d9 100644 --- a/tests/WP_Ultimo/Managers/Site_Manager_Test.php +++ b/tests/WP_Ultimo/Managers/Site_Manager_Test.php @@ -3033,12 +3033,8 @@ public function test_frontend_my_sites_toolbar_optimization_requires_admin_bar_i $manager = $this->get_manager_instance(); $user_id = $this->factory()->user->create(['role' => 'administrator']); $method = new \ReflectionMethod(Site_Manager::class, 'is_frontend_my_sites_toolbar_request'); - $filter = function ($value, $setting) { - return 'optimize_frontend_my_sites_toolbar' === $setting ? 1 : $value; - }; wp_set_current_user($user_id); - add_filter('wu_get_setting', $filter, 10, 2); add_filter('show_admin_bar', '__return_true'); try { @@ -3069,7 +3065,6 @@ public function test_frontend_my_sites_toolbar_optimization_requires_admin_bar_i ); $this->assertFalse($method->invoke($manager, [])); } finally { - remove_filter('wu_get_setting', $filter, 10); remove_filter('show_admin_bar', '__return_true'); wp_set_current_user(0); } @@ -3088,12 +3083,8 @@ public function test_frontend_my_sites_toolbar_optimization_supports_admin_bar_s $user_id = $this->factory()->user->create(['role' => 'administrator']); $method = new \ReflectionMethod(Site_Manager::class, 'is_frontend_my_sites_toolbar_request'); $admin_bar = new class() extends \WP_Admin_Bar {}; - $filter = function ($value, $setting) { - return 'optimize_frontend_my_sites_toolbar' === $setting ? 1 : $value; - }; wp_set_current_user($user_id); - add_filter('wu_get_setting', $filter, 10, 2); add_filter('show_admin_bar', '__return_true'); try { @@ -3109,16 +3100,15 @@ public function test_frontend_my_sites_toolbar_optimization_supports_admin_bar_s ]) ); } finally { - remove_filter('wu_get_setting', $filter, 10); remove_filter('show_admin_bar', '__return_true'); wp_set_current_user(0); } } /** - * Test front-end My Sites toolbar optimization remains disabled by default. + * Test My Sites toolbar optimization is enabled without a settings opt-in. */ - public function test_frontend_my_sites_toolbar_optimization_is_disabled_by_default(): void { + public function test_frontend_my_sites_toolbar_optimization_is_enabled_by_default(): void { $manager = $this->get_manager_instance(); $user_id = $this->factory()->user->create(['role' => 'administrator']); @@ -3128,8 +3118,11 @@ public function test_frontend_my_sites_toolbar_optimization_is_disabled_by_defau add_filter('show_admin_bar', '__return_true'); try { - $this->assertFalse( + $this->assertTrue( $method->invoke($manager, [ + [ + 'function' => 'get_blogs_of_user', + ], [ 'class' => 'WP_Admin_Bar', 'function' => 'initialize', @@ -3175,25 +3168,27 @@ public function test_frontend_my_sites_toolbar_optimization_runs_during_admin_ba require_once ABSPATH . WPINC . '/class-wp-admin-bar.php'; } + $manager = $this->get_manager_instance(); $user_id = $this->factory()->user->create(['role' => 'administrator']); - $filter = function ($value, $setting) { - return 'optimize_frontend_my_sites_toolbar' === $setting ? 1 : $value; - }; grant_super_admin($user_id); wp_set_current_user($user_id); set_current_screen('front'); - add_filter('wu_get_setting', $filter, 10, 2); add_filter('show_admin_bar', '__return_true'); try { $admin_bar = new \WP_Admin_Bar(); $admin_bar->initialize(); + wp_admin_bar_my_sites_menu($admin_bar); + $manager->add_lazy_my_sites_toolbar_controls($admin_bar); $this->assertCount(1, $admin_bar->user->blogs); $this->assertArrayHasKey(get_current_blog_id(), $admin_bar->user->blogs); + $this->assertSame( + $admin_bar->get_node('my-sites')->href, + $admin_bar->get_node('wu-my-sites-toolbar-all')->href + ); } finally { - remove_filter('wu_get_setting', $filter, 10); remove_filter('show_admin_bar', '__return_true'); revoke_super_admin($user_id); wp_set_current_user(0); @@ -3226,6 +3221,41 @@ public function test_frontend_my_sites_toolbar_optimization_respects_all_flag(): } } + /** + * Test the lazy toolbar returns bounded pages without duplicating the current site. + */ + public function test_my_sites_toolbar_returns_paginated_additional_sites(): void { + + $manager = $this->get_manager_instance(); + $user_id = $this->factory()->user->create(['role' => 'administrator']); + $method = new \ReflectionMethod(Site_Manager::class, 'get_my_sites_toolbar_page_data'); + + for ($index = 0; $index < 41; $index++) { + $this->factory()->blog->create(['user_id' => $user_id]); + } + + wp_set_current_user($user_id); + $current_blog_id = get_current_blog_id(); + + try { + $page_one = $method->invoke($manager, $user_id, 1); + $page_two = $method->invoke($manager, $user_id, 2); + $one_ids = wp_list_pluck($page_one['sites'], 'id'); + $two_ids = wp_list_pluck($page_two['sites'], 'id'); + + $this->assertLessThanOrEqual(20, count($page_one['sites'])); + $this->assertTrue($page_one['has_more']); + $this->assertSame(2, $page_one['next_page']); + $this->assertLessThanOrEqual(20, count($page_two['sites'])); + $this->assertSame([], array_values(array_intersect($one_ids, $two_ids))); + $this->assertNotContains($current_blog_id, $one_ids); + $this->assertStringContainsString('wu_admin_bar_magic_link', $page_one['sites'][0]['dashboard_url']); + $this->assertSame($current_blog_id, get_current_blog_id()); + } finally { + wp_set_current_user(0); + } + } + // ======================================================================== // init – demo-related hooks // ======================================================================== From 18017e6ee73b4e1714b2be6a5280864430c2b676 Mon Sep 17 00:00:00 2001 From: David Stone Date: Thu, 22 Oct 2026 15:00:05 -0600 Subject: [PATCH 2/2] docs: describe lazy My Sites toolbar --- DESIGN.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/DESIGN.md b/DESIGN.md index acbcdff75..ae37262aa 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -22,3 +22,13 @@ ring, while the selected control retains the blue card border. Contact-us cards use a non-submitting button so they remain keyboard operable without becoming a checkout selection. After a plan refresh, focus returns to the selected radio only when the visitor has not moved it elsewhere during the request. + +## My Sites toolbar + +The WordPress My Sites toolbar keeps the current site and the full My Sites +administration link available without JavaScript. Additional sites load only +when the menu is opened, in bounded pages, with an explicit load-more action. +Loading, empty, and failed states stay in the menu; failed requests provide a +retry link. Keyboard, hover, and touch can request the first page, while Escape +returns focus to the menu trigger. Lazy dashboard links retain the existing +same-origin magic-link handoff.