Skip to content

Display notification badge in the admin menu when there are recommendations #3001

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 9 commits into
base: feature/GOOWOO-172-pmax-assets-improvements
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions js/src/notification-manager/index.js
Original file line number Diff line number Diff line change
@@ -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' ],
} );
} )();
3 changes: 3 additions & 0 deletions src/Internal/DependencyManagement/AdminServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 );
Expand Down
4 changes: 3 additions & 1 deletion src/Menu/Dashboard.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -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,
]
);
Expand Down
205 changes: 205 additions & 0 deletions src/Menu/NotificationManager.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\Menu;

use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Registerable;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\Admin\PageController;
use Automattic\WooCommerce\GoogleListingsAndAds\Assets\AdminScriptWithBuiltDependenciesAsset;
use Automattic\WooCommerce\GoogleListingsAndAds\Assets\AssetsHandlerInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\PluginHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Value\BuiltScriptDependencyArray;

/**
* Class NotificationManager
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Menu
*
* Manages the display of a single, aggregated notification pill in the admin menu.
* It relies on a filter to gather the total count from various contributors.
*/
class NotificationManager implements Service, Registerable {

use PluginHelper;

/**
* @var AssetsHandlerInterface
*/
protected $assets_handler;

/**
* NotificationManager constructor.
*
* @param AssetsHandlerInterface $assets_handler
*/
public function __construct( AssetsHandlerInterface $assets_handler ) {
$this->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 = ' <span class="update-plugins count-' . $total_notification_count . '"><span class="update-count">' . $total_notification_count . '</span></span>';

// 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;
}
}
79 changes: 79 additions & 0 deletions tests/Unit/Menu/NotificationManagerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\Tests\Unit\Google;

use Automattic\WooCommerce\GoogleListingsAndAds\Assets\AssetsHandlerInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Menu\NotificationManager;
use Automattic\WooCommerce\GoogleListingsAndAds\Tests\Framework\UnitTest;
use Automattic\WooCommerce\Admin\PageController;
use PHPUnit\Framework\MockObject\MockObject;

defined( 'ABSPATH' ) || exit;

/**
* Class NotificationManagerTest
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Tests\Unit\Google
*/
class NotificationManagerTest extends UnitTest {

/** @var MockObject|AssetsHandlerInterface $assets_handler */
protected $assets_handler;

/** @var NotificationManager $notification_manager */
protected $notification_manager;

/**
* Runs before each test is executed.
*/
public function setUp(): void {
parent::setUp();

$this->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 = [];

Check warning on line 43 in tests/Unit/Menu/NotificationManagerTest.php

View workflow job for this annotation

GitHub Actions / PHP coding standards

Equals sign not aligned with surrounding assignments; expected 2 spaces but found 1 space

Check failure on line 43 in tests/Unit/Menu/NotificationManagerTest.php

View workflow job for this annotation

GitHub Actions / PHP coding standards

Overriding WordPress globals is prohibited. Found assignment to $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;

Check failure on line 54 in tests/Unit/Menu/NotificationManagerTest.php

View workflow job for this annotation

GitHub Actions / PHP coding standards

Overriding WordPress globals is prohibited. Found assignment to $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 = [];

Check warning on line 62 in tests/Unit/Menu/NotificationManagerTest.php

View workflow job for this annotation

GitHub Actions / PHP coding standards

Equals sign not aligned with surrounding assignments; expected 2 spaces but found 1 space

Check failure on line 62 in tests/Unit/Menu/NotificationManagerTest.php

View workflow job for this annotation

GitHub Actions / PHP coding standards

Overriding WordPress globals is prohibited. Found assignment to $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;

Check failure on line 77 in tests/Unit/Menu/NotificationManagerTest.php

View workflow job for this annotation

GitHub Actions / PHP coding standards

Overriding WordPress globals is prohibited. Found assignment to $wp_filter
}
}
5 changes: 5 additions & 0 deletions webpack.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading