diff --git a/js/src/notification-manager/index.js b/js/src/notification-manager/index.js new file mode 100644 index 0000000000..9fba2b94d0 --- /dev/null +++ b/js/src/notification-manager/index.js @@ -0,0 +1,49 @@ +/* global MutationObserver */ +( function () { + const badge = document.querySelector( + '#toplevel_page_woocommerce-marketing .update-plugins' + ); + + if ( ! badge ) { + return; + } + + const marketingMenu = document.getElementById( + 'toplevel_page_woocommerce-marketing' + ); + + const observer = new MutationObserver( function () { + if ( marketingMenu.classList.contains( 'wp-has-current-submenu' ) ) { + const subMenu = document.querySelector( + '[href="admin.php?page=wc-admin&path=%2Fgoogle%2Fdashboard"]' + ); + + if ( subMenu && ! subMenu.contains( badge ) ) { + // Ensure there is white space between the badge and menu title for visual consistency. + subMenu.textContent.trimEnd(); + subMenu.textContent += ' '; + + // Move the badge to the correct location. + subMenu.appendChild( badge ); + } + } else { + const topMenu = document.querySelector( + '.toplevel_page_woocommerce-marketing > a > .wp-menu-name' + ); + + if ( topMenu && ! topMenu.contains( badge ) ) { + // Ensure there is white space between the badge and menu title for visual consistency. + topMenu.textContent.trimEnd(); + topMenu.textContent += ' '; + + // Move the badge to the correct location. + topMenu.appendChild( badge ); + } + } + } ); + + observer.observe( marketingMenu, { + attributes: true, + attributeFilter: [ 'class' ], + } ); +} )(); diff --git a/src/Internal/DependencyManagement/AdminServiceProvider.php b/src/Internal/DependencyManagement/AdminServiceProvider.php index 26f5e56c49..92fea25f7d 100644 --- a/src/Internal/DependencyManagement/AdminServiceProvider.php +++ b/src/Internal/DependencyManagement/AdminServiceProvider.php @@ -21,6 +21,7 @@ use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service; use Automattic\WooCommerce\GoogleListingsAndAds\Menu\AttributeMapping; use Automattic\WooCommerce\GoogleListingsAndAds\Menu\Dashboard; +use Automattic\WooCommerce\GoogleListingsAndAds\Menu\NotificationManager; use Automattic\WooCommerce\GoogleListingsAndAds\Menu\GetStarted; use Automattic\WooCommerce\GoogleListingsAndAds\Menu\ProductFeed; use Automattic\WooCommerce\GoogleListingsAndAds\Menu\Reports; @@ -57,6 +58,7 @@ class AdminServiceProvider extends AbstractServiceProvider implements Conditiona ConnectionTest::class => true, CouponBulkEdit::class => true, Dashboard::class => true, + NotificationManager::class => true, GetStarted::class => true, MetaBoxInterface::class => true, MetaBoxInitializer::class => true, @@ -101,6 +103,7 @@ public function register(): void { $this->share_with_tags( AttributeMapping::class ); $this->share_with_tags( Dashboard::class ); + $this->share_with_tags( NotificationManager::class, AssetsHandlerInterface::class ); $this->share_with_tags( GetStarted::class ); $this->share_with_tags( ProductFeed::class ); $this->share_with_tags( Reports::class ); diff --git a/src/Menu/Dashboard.php b/src/Menu/Dashboard.php index 0016ea461d..9198e2a67f 100644 --- a/src/Menu/Dashboard.php +++ b/src/Menu/Dashboard.php @@ -20,6 +20,8 @@ class Dashboard implements Service, Registerable, MerchantCenterAwareInterface { public const PATH = '/google/dashboard'; + public const MARKETING_MENU_SLUG = 'woocommerce-marketing'; + /** * Register a service. */ @@ -35,7 +37,7 @@ function () { [ 'id' => 'google-listings-and-ads', 'title' => __( 'Google for WooCommerce', 'google-listings-and-ads' ), - 'parent' => 'woocommerce-marketing', + 'parent' => self::MARKETING_MENU_SLUG, 'path' => self::PATH, ] ); diff --git a/src/Menu/NotificationManager.php b/src/Menu/NotificationManager.php new file mode 100644 index 0000000000..e4320fc60d --- /dev/null +++ b/src/Menu/NotificationManager.php @@ -0,0 +1,205 @@ +assets_handler = $assets_handler; + } + + /** + * Register the service, hooking into admin_menu to display notifications. + */ + public function register(): void { + // Hook into admin_menu with a high priority (e.g., 20) to ensure + // all other menu items have been registered by WooCommerce and other plugins. + add_action( 'admin_menu', [ $this, 'display_aggregated_notification_pill' ], 20 ); + + // Register assets. + $this->register_assets(); + } + + /** + * Register assets. + * + * @return void + */ + private function register_assets() { + $notification_manager = new AdminScriptWithBuiltDependenciesAsset( + 'notification-manager', + 'js/build/notification-manager', + "{$this->get_root_dir()}/js/build/notification-manager.asset.php", + new BuiltScriptDependencyArray( + [ + 'dependencies' => [], + 'version' => $this->get_version(), + ] + ), + function () { + return PageController::is_admin_page(); + } + ); + + $this->assets_handler->register( $notification_manager ); + + add_action( + 'admin_enqueue_scripts', + function () use ( $notification_manager ) { + if ( $this->is_marketing_page() || $this->is_analytics_page() ) { + $this->assets_handler->enqueue( $notification_manager ); + } + } + ); + } + + /** + * Determines if the current admin page is a child page within the WooCommerce Marketing section. + * This logic is crucial for deciding where the notification pill should be placed. + * + * @return bool True if the current page is a Marketing child page, false otherwise. + */ + private function is_marketing_page() { + global $pagenow; + + $current_page_slug = isset( $_GET['page'] ) ? sanitize_text_field( wp_unslash( $_GET['page'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification + $current_page_path = isset( $_GET['path'] ) ? sanitize_text_field( wp_unslash( $_GET['path'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification + $current_post_type = isset( $_GET['post_type'] ) ? sanitize_text_field( wp_unslash( $_GET['post_type'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification + + $post_type_page = add_query_arg( + [ + 'post_type' => $current_post_type, + ], + $pagenow + ); + + $page_path_fragment = str_replace( + 'page=', + '', + build_query( + [ + 'page' => $current_page_slug, + 'path' => $current_page_path, + ] + ) + ); + + $page_controller_pages = PageController::get_instance()->get_pages(); + $marketing_menu_slug = Dashboard::MARKETING_MENU_SLUG; + + $marketing_menu_pages = array_filter( + $page_controller_pages, + static function ( $page ) use ( $marketing_menu_slug ) { + return isset( $page['parent'] ) && $page['parent'] === $marketing_menu_slug; + } + ); + + $is_marketing_page = false; + + foreach ( $marketing_menu_pages as $page ) { + if ( isset( $page['path'] ) && in_array( $page['path'], [ $post_type_page, $page_path_fragment ], true ) ) { + $is_marketing_page = true; + break; + } + } + + return $is_marketing_page; + } + + /** + * Displays an aggregated notification pill in the admin menu. + * This method is hooked to 'admin_menu'. + */ + public function display_aggregated_notification_pill(): void { + global $menu, $submenu; + + // Initialize the count and apply the filter to get the total aggregated count. + // All parts of the plugin (and other plugins) that need to add to the notification + // should hook into this filter. + $total_notification_count = apply_filters( 'google_for_woocommerce_admin_menu_notification_count', 0 ); + + // Only proceed if there's at least one notification. + if ( $total_notification_count > 0 ) { + $badge_html = ' ' . $total_notification_count . ''; + + // Determine if the current page being loaded is within the Marketing section. + $is_on_marketing_child_page = $this->is_marketing_page(); + + if ( $is_on_marketing_child_page ) { + // If on a Marketing child page, add the pill to the 'Google for WooCommerce' sub-menu item. + // This means the user has the Marketing menu expanded and is viewing one of its sub-pages. + $marketing_parent_slug = Dashboard::MARKETING_MENU_SLUG; // Use constant for parent slug + $google_for_woocommerce_menu_path = Dashboard::PATH; // Use constant for GfW path + + if ( isset( $submenu[ $marketing_parent_slug ] ) ) { + foreach ( $submenu[ $marketing_parent_slug ] as $key => $submenu_item ) { + // Use the submenu's slug (index 2) for robustness against translations. + // The slug will contain the path defined by the plugin. + if ( isset( $submenu_item[2] ) && strpos( $submenu_item[2], $google_for_woocommerce_menu_path ) !== false ) { + $submenu[ $marketing_parent_slug ][ $key ][0] .= $badge_html; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + break; + } + } + } + } else { + foreach ( $menu as $key => $menu_item ) { + // Use the top-level menu's slug (index 2) for robustness against translations. + if ( isset( $menu_item[2] ) && Dashboard::MARKETING_MENU_SLUG === $menu_item[2] ) { + $menu[ $key ][0] .= $badge_html; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + break; + } + } + } + } + } + + /** + * Determines if the current admin page is the Analytics. + * + * @return bool True if the current menu item is the Analytics menu or one of it's sub menus. + */ + private function is_analytics_page(): bool { + $current_page_slug = isset( $_GET['page'] ) ? sanitize_text_field( wp_unslash( $_GET['page'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification + if ( $current_page_slug !== 'wc-admin' ) { + return false; + } + + $current_page_path = isset( $_GET['path'] ) ? sanitize_text_field( wp_unslash( $_GET['path'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification + $parts = explode( '/', ltrim( $current_page_path, '/' ) ); + + if ( isset( $parts[0] ) && $parts[0] === 'analytics' ) { + return true; + } + + return false; + } +} diff --git a/tests/Unit/Menu/NotificationManagerTest.php b/tests/Unit/Menu/NotificationManagerTest.php new file mode 100644 index 0000000000..1ebb17b909 --- /dev/null +++ b/tests/Unit/Menu/NotificationManagerTest.php @@ -0,0 +1,79 @@ +assets_handler = $this->createMock( AssetsHandlerInterface::class ); + + $this->notification_manager = new NotificationManager( $this->assets_handler ); + } + + public function test_notification_script_not_added_to_non_woocommerce_admin_pages() { + global $wp_filter; + + // Create a backup and clear all hooks. + $_wp_filter = $wp_filter; + $wp_filter = []; + + // The script will be registered but not enqueued. + $this->assets_handler->expects( $this->once() )->method( 'register' ); + $this->assets_handler->expects( $this->never() )->method( 'enqueue' ); + + $this->notification_manager->register(); + + do_action( 'admin_enqueue_scripts' ); + + // Restore hooks. + $wp_filter = $_wp_filter; + } + + public function test_notification_script_is_added_to_analytics_admin_pages() { + global $wp_filter; + + // Create a backup and clear all hooks. + $_wp_filter = $wp_filter; + $wp_filter = []; + + // Mock being on a Marketing admin page. + $_GET['page'] = 'wc-admin'; + $_GET['path'] = '/analytics/overview'; + + // The script will be registered but not enqueued. + $this->assets_handler->expects( $this->once() )->method( 'register' ); + $this->assets_handler->expects( $this->once() )->method( 'enqueue' ); + + $this->notification_manager->register(); + + do_action( 'admin_enqueue_scripts' ); + + // Restore hooks. + $wp_filter = $_wp_filter; + } +} diff --git a/webpack.config.js b/webpack.config.js index 1ab9815919..3e43272fd6 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -106,6 +106,11 @@ const webpackConfig = { 'js/src/wp-consent-api', 'index.js' ), + 'notification-manager': path.resolve( + process.cwd(), + 'js/src/notification-manager', + 'index.js' + ), } ), output: { ...defaultConfig.output,