HEX
Server: Apache/2.4.66 (Debian)
System: Linux 6dfabc3b2241 6.8.0-71-generic #71-Ubuntu SMP PREEMPT_DYNAMIC Tue Jul 22 16:52:38 UTC 2025 x86_64
User: (1000)
PHP: 8.3.30
Disabled: NONE
Upload Files
File: /var/www/html/wp-content/plugins/wp-graphql/src/Admin/AdminNotices.php
<?php

namespace WPGraphQL\Admin;

/**
 * This class isn't intended for direct extending or customizing.
 *
 * This class is responsible for handling the management and display of admin notices
 * related directly to WPGraphQL.
 *
 * Breaking changes to this class will not be considered a semver breaking change as there's no
 * expectation that users will be calling these functions directly or extending this class.
 *
 * @phpstan-type AdminNoticeConfig array{
 *   message: string,
 *   type?: 'error'|'warning'|'success'|'info',
 *   is_dismissable?: bool,
 *   conditions?: callable():bool,
 * }
 *
 * @internal
 */
class AdminNotices {

	/**
	 * Stores the singleton instance of the class
	 *
	 * @var self|null
	 */
	private static $instance = null;

	/**
	 * Stores the admin notices to display
	 *
	 * @var array<string,AdminNoticeConfig>
	 */
	protected $admin_notices = [];

	/**
	 * @var array<string>
	 */
	protected $dismissed_notices = [];

	/**
	 * Private constructor to prevent direct instantiation
	 */
	private function __construct() {
		// Initialize the class (can move code from init() here if desired)
	}

	/**
	 * Prevent cloning the instance
	 */
	public function __clone() {}

	/**
	 * Prevent unserializing the instance
	 */
	public function __wakeup() {}

	/**
	 * Get the singleton instance of the class
	 */
	public static function get_instance(): self {
		if ( null === self::$instance ) {
			self::$instance = new self();
			self::$instance->init();
		}
		return self::$instance;
	}

	/**
	 * Initialize the Admin Notices class
	 */
	public function init(): void {

		register_graphql_admin_notice(
			'wpgraphql-acf-announcement',
			[
				'type'           => 'info',
				'message'        => __( 'You are using WPGraphQL and Advanced Custom Fields. Have you seen the new <a href="https://acf.wpgraphql.com/" target="_blank" rel="nofollow">WPGraphQL for ACF</a>?', 'wp-graphql' ),
				'is_dismissable' => true,
				'conditions'     => static function () {
					if ( ! class_exists( 'ACF' ) ) {
						return false;
					}

					// Bail if new version of WPGraphQL for ACF is active.
					if ( class_exists( 'WPGraphQLAcf' ) ) {
						return false;
					}

					return true;
				},
			]
		);

		// Initialize Admin Notices. This is where register_graphql_admin_notice hooks in
		do_action( 'graphql_admin_notices_init', $this );

		$current_user_id         = get_current_user_id();
		$this->dismissed_notices = get_user_meta( $current_user_id, 'wpgraphql_dismissed_admin_notices', true ) ?: [];

		// Filter the notices to remove any dismissed notices
		$this->pre_filter_dismissed_notices();

		add_action( 'admin_notices', [ $this, 'maybe_display_notices' ] );
		add_action( 'network_admin_notices', [ $this, 'maybe_display_notices' ] );
		add_action( 'admin_init', [ $this, 'handle_dismissal_of_notice' ] );
		add_action( 'admin_menu', [ $this, 'add_notification_bubble' ], 100 );
	}

	/**
	 * Pre-filters dismissed notices from the admin notices array.
	 */
	protected function pre_filter_dismissed_notices(): void {

		// remove any notice that's been dismissed
		foreach ( $this->dismissed_notices as $dismissed_notice ) {
			$this->remove_admin_notice( $dismissed_notice );
		}

		// For all remaining notices, run the callback to see if it's actually relevant
		foreach ( $this->admin_notices as $notice_slug => $notice ) {
			if ( ! isset( $notice['conditions'] ) ) {
				continue;
			}

			if ( ! is_callable( $notice['conditions'] ) ) {
				continue;
			}

			if ( false === $notice['conditions']() && ! is_network_admin() ) {
				$this->remove_admin_notice( $notice_slug );
			}
		}
	}

	/**
	 * Return all admin notices
	 *
	 * @return array<string,AdminNoticeConfig>
	 */
	public function get_admin_notices(): array {
		return $this->admin_notices;
	}

	/**
	 * @param string            $slug   The slug identifying the admin notice
	 * @param AdminNoticeConfig $config The config of the admin notice
	 *
	 * @return AdminNoticeConfig|array{}
	 */
	public function add_admin_notice( string $slug, array $config ): array {
		/**
		 * Pass the notice through a filter before registering it
		 *
		 * @param AdminNoticeConfig $config The config of the admin notice
		 * @param string            $slug   The slug identifying the admin notice
		 */
		$filtered_notice = apply_filters( 'graphql_add_admin_notice', $config, $slug );

		// If not a valid config, bail early.
		if ( ! $this->is_valid_config( $filtered_notice ) ) {
			return [];
		}

		$this->admin_notices[ $slug ] = $filtered_notice;
		return $this->admin_notices[ $slug ];
	}

	/**
	 * Throw an error if the config is not valid.
	 *
	 * @since v1.21.0
	 *
	 * @param array<string,mixed> $config The config of the admin notice
	 *
	 * @phpstan-assert-if-true array{
	 *  message: string,
	 *  type?: 'error'|'warning'|'success'|'info',
	 *  is_dismissable?: bool,
	 *  conditions?: callable,
	 * } $config
	 */
	public function is_valid_config( array $config ): bool {
		if ( empty( $config['message'] ) ) {
			_doing_it_wrong( 'register_graphql_admin_notice', esc_html__( 'Config message is required', 'wp-graphql' ), '1.21.0' );
			return false;
		}

		if ( isset( $config['conditions'] ) && ! is_callable( $config['conditions'] ) ) {
			_doing_it_wrong( 'register_graphql_admin_notice', esc_html__( 'Config conditions should be callable', 'wp-graphql' ), '1.21.0' );
			return false;
		}

		if ( isset( $config['type'] ) && ! in_array( $config['type'], [ 'error', 'warning', 'success', 'info' ], true ) ) {
			_doing_it_wrong( 'register_graphql_admin_notice', esc_html__( 'Config type should be one of the following: error | warning | success | info', 'wp-graphql' ), '1.21.0' );
			return false;
		}

		if ( isset( $config['is_dismissable'] ) && ! is_bool( $config['is_dismissable'] ) ) {
			_doing_it_wrong( 'register_graphql_admin_notice', esc_html__( 'is_dismissable should be a boolean', 'wp-graphql' ), '1.21.0' );
			return false;
		}

		return true;
	}

	/**
	 * Given the slug of an admin notice, remove it from the notices
	 *
	 * @param string $slug The slug identifying the admin notice to remove
	 *
	 * @return array<string,AdminNoticeConfig>
	 */
	public function remove_admin_notice( string $slug ): array {
		unset( $this->admin_notices[ $slug ] );
		return $this->admin_notices;
	}

	/**
	 * Determine whether a notice is dismissable or not
	 *
	 * @param AdminNoticeConfig|array{} $notice The notice to check whether its dismissable or not
	 */
	public function is_notice_dismissable( array $notice = [] ): bool {
		return ( ! isset( $notice['is_dismissable'] ) || false !== (bool) $notice['is_dismissable'] );
	}

	/**
	 * Display notices if they are displayable
	 */
	public function maybe_display_notices(): void {
		if ( ! $this->is_plugin_scoped_page() ) {
			return;
		}

		$this->render_notices();
	}

	/**
	 * Adds the notification count to the menu item.
	 */
	public function add_notification_bubble(): void {
		global $menu;

		$admin_notices = $this->get_admin_notices();

		$notice_count = count( $admin_notices );

		if ( 0 === $notice_count ) {
			return;
		}

		foreach ( $menu as $key => $item ) {
			if ( 'graphiql-ide' === $item[2] ) {
                // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
				$menu[ $key ][0] .= ' <span class="update-plugins count-' . absint( $notice_count ) . '"><span class="plugin-count">' . absint( $notice_count ) . '</span></span>';
				break;
			}
		}
	}

	/**
	 * Render the notices.
	 */
	protected function render_notices(): void {

		$notices = $this->get_admin_notices();

		if ( empty( $notices ) ) {
			return;
		}
		?>
		<style>
			/* Only display the ACF notice */
			body.toplevel_page_graphiql-ide #wpbody .wpgraphql-admin-notice {
				display: block;
				position: absolute;
				top: 0;
				right: 0;
				z-index: 1;
				min-width: 40%;
			}
			body.toplevel_page_graphiql-ide #wpbody #wp-graphiql-wrapper {
				margin-top: <?php echo count( $notices ) * 45; ?>px;
			}
			.wpgraphql-admin-notice {
				position: relative;
				text-decoration: none;
				padding: 1px 40px 1px 12px;
			}
			.wpgraphql-admin-notice .notice-dismiss {
				text-decoration: none;
			}

		</style>
		<?php
		$count = 0;

		/**
		 * Fires before the admin notices are rendered.
		 *
		 * @param array<string,AdminNoticeConfig> $notices The notices to be rendered
		 *
		 * @since v1.23.0
		 */
		do_action( 'graphql_admin_notices_render_notices', $notices );

		foreach ( $notices as $notice_slug => $notice ) {
			$type = $notice['type'] ?? 'info';
			?>
			<style>
				body.toplevel_page_graphiql-ide #wpbody #wpgraphql-admin-notice-<?php echo esc_attr( $notice_slug ); ?> {
					top: <?php echo esc_attr( ( $count * 45 ) . 'px' ); ?>
				}
			</style>
			<div id="wpgraphql-admin-notice-<?php echo esc_attr( $notice_slug ); ?>" class="wpgraphql-admin-notice notice notice-<?php echo esc_attr( $type ); ?> <?php echo $this->is_notice_dismissable( $notice ) ? 'is-dismissable' : ''; ?>">
				<p><?php echo ! empty( $notice['message'] ) ? wp_kses_post( $notice['message'] ) : ''; ?></p>
				<?php
				$is_dismissable = $this->is_notice_dismissable( $notice );
				if ( $is_dismissable ) {
					$dismiss_acf_nonce = wp_create_nonce( 'wpgraphql_disable_notice_nonce' );
					$dismiss_url       = add_query_arg(
						[
							'wpgraphql_disable_notice_nonce' => $dismiss_acf_nonce,
							'wpgraphql_disable_notice' => $notice_slug,
						]
					);
					?>
					<a href="<?php echo esc_url( $dismiss_url ); ?>" class="notice-dismiss">
						<span class="screen-reader-text"><?php esc_html_e( 'Dismiss', 'wp-graphql' ); ?></span>
					</a>
				<?php } ?>
			</div>
			<?php
			/**
			 * Fires for each admin notice that is rendered.
			 *
			 * @param string $notice_slug The slug of the notice
			 * @param AdminNoticeConfig $notice The notice to be rendered
			 * @param bool $is_dismissable Whether the notice is dismissable or not
			 * @param int $count The count of the notice
			 *
			 * @since v1.23.0
			 */
			do_action( 'graphql_admin_notices_render_notice', $notice_slug, $notice, $is_dismissable, $count );
			++$count;
		}
	}

	/**
	 * Checks if the current admin page is within the scope of the plugin's own pages.
	 *
	 * @return bool True if the current page is within scope of the plugin's pages.
	 */
	protected function is_plugin_scoped_page(): bool {
		$screen = get_current_screen();

		// Guard clause for invalid screen.
		if ( ! $screen ) {
			return false;
		}

		$allowed_pages = [
			'plugins',
			'plugins-network',
			'toplevel_page_graphiql-ide',
			'graphql_page_graphql-settings',
		];

		$current_page_id = $screen->id;

		$is_allowed_admin_page = in_array( $current_page_id, $allowed_pages, true );

		/**
		 * Filter to determine if the current admin page is within the scope of the plugin's own pages.
		 * This filter can be used to add additional pages to the list of allowed pages.
		 *
		 * The filter receives the following arguments:
		 *
		 * @param bool $is_plugin_scoped_page True if the current page is within scope of the plugin's pages.
		 * @param string $current_page_id The ID of the current admin page.
		 * @param array<string> $allowed_pages The list of allowed pages.
		 */
		return apply_filters( 'graphql_admin_notices_is_allowed_admin_page', $is_allowed_admin_page, $current_page_id, $allowed_pages );
	}

	/**
	 * Handles the dismissal of the ACF notice.
	 * set_transient reference: https://developer.wordpress.org/reference/functions/set_transient/
	 * This function sets a transient to remember the dismissal status of the notice.
	 */
	public function handle_dismissal_of_notice(): void {
		if ( ! isset( $_GET['wpgraphql_disable_notice_nonce'], $_GET['wpgraphql_disable_notice'] ) ) {
			return;
		}

		$nonce       = sanitize_text_field( wp_unslash( $_GET['wpgraphql_disable_notice_nonce'] ) );
		$notice_slug = sanitize_text_field( wp_unslash( $_GET['wpgraphql_disable_notice'] ) );

		if ( empty( $notice_slug ) || ! wp_verify_nonce( $nonce, 'wpgraphql_disable_notice_nonce' ) ) {
			return;
		}

		$current_user_id = get_current_user_id();

		$disabled   = get_user_meta( $current_user_id, 'wpgraphql_dismissed_admin_notices', true ) ?: [];
		$disabled[] = $notice_slug;

		update_user_meta( $current_user_id, 'wpgraphql_dismissed_admin_notices', array_unique( $disabled ) );

		// Redirect to clear URL parameters
		wp_safe_redirect( remove_query_arg( [ 'wpgraphql_disable_notice_nonce', 'wpgraphql_disable_notice' ] ) );
		exit();
	}
}