Dismissible notices that persist when refreshing the WordPress admin screens

In this article we will go through some code that I like to use to make dismissible  notices where the dismissal persists between page refreshes in the WordPress administration screens.

 

Make dismissible admin notices where the dismissal persists between page refreshes in the WordPress administration screens.

Some things to note about dismissible notices

Don’t you just hate how this functionality has still not made it into core? Legend has it that dismissible notices were introduced back in WordPress 4.2, but that only means that when you click the little “x” at the top right, the notice box becomes hidden. The way to enable this is that you add a .is-dismissible class into your notice’s markup:

<div class="notice notice-warn is-dismissible">your message here</div>

And that hides the box. Until the next page refresh. This is not only annoying, it also goes against the WordPress.org guidelines:

Upgrade prompts, notices, and alerts should be limited in scope and used sparingly or only on the plugin’s setting page. Any site wide notices or embedded dashboard widgets must be dismissible. Error messages and alerts should include information on how to resolve the situation, and remove themselves when completed.

So for those of us aspiring to be hosted on WordPress.org it’s a necessity, rather than a luxury feature, to be able to let the user dismiss notices persistently.

Requirements analysis

WordPress tradition has it that you store an option in the database. That way you know not to show the same notice again. But this is something that you have to do over and over again, so it’s exactly the kind of thing that you want to include in all your code as a library. You want something that can be easily called from wherever, so the tried and true singleton pattern will do. You need the ability to be able to assign a slug to your notice, so you know how to store it in the options table. But you also want to be able to show the occasional non-dismissible notice. Finally, let’s be nice and clean up after ourselves. The options will be deleted when our plugin is uninstalled.

Let’s do this!

Writing code with class

We need something that can be included from our plugin (or theme). Let’s make a singleton class that can hold arrays of different types of notices (success, info, warn, error):

<?php

// don't load directly
defined( 'ABSPATH' ) || die( '-1' );

if ( ! class_exists( 'MyPlugin_Admin_Notices' ) ) {

    class MyPlugin_Admin_Notices {

        private static $_instance;
        private $admin_notices;
        const TYPES = 'error,warning,info,success';

        private function __construct() {
            $this->admin_notices = new stdClass();
            foreach ( explode( ',', self::TYPES ) as $type ) {
                $this->admin_notices->{$type} = array();
            }
        }

        public static function get_instance() {
            if ( ! ( self::$_instance instanceof self ) ) {
                self::$_instance = new self();
            }
            return self::$_instance;
        }
    }
}

MyPlugin_Admin_Notices::get_instance();

Nice. This is a useful bucket where we can throw in notices. They will be retrieved for rendering only later, when we will add code to the admin_notices action.

An API for entering notices from our code

For now, let’s add in some methods to our class for populating our notice arrays:

public function error( $message, $dismiss_option = false ) {
    $this->notice( 'error', $message, $dismiss_option );
}

public function warning( $message, $dismiss_option = false ) {
    $this->notice( 'warning', $message, $dismiss_option );
}

public function success( $message, $dismiss_option = false ) {
    $this->notice( 'success', $message, $dismiss_option );
}

public function info( $message, $dismiss_option = false ) {
    $this->notice( 'info', $message, $dismiss_option );
}

private function notice( $type, $message, $dismiss_option ) {
    $notice = new stdClass();
    $notice->message = $message;
    $notice->dismiss_option = $dismiss_option;

    $this->admin_notices->{$type}[] = $notice;
}

Notice the visibility of the functions. The four public functions that comprise our API all defer to the private function that does the data collection. We’ll let the user enter a message string, and optionally give a slug with the dismiss_option parameter. If set, this will be part of the database option’s slug.

Writing the markup

We have a mechanism for adding notices into our memory, now let’s dump them to the admin area as markup. This will happen on the admin_notices action, so first add this to the constructor:

add_action( 'admin_notices', array( &$this, 'action_admin_notices' ) );

and then let’s actually write the markup. I’ve chosen to show notices in decreasing levels of severity, hence the nested loops:

public function action_admin_notices() {
    foreach ( explode( ',', self::TYPES ) as $type ) {
        foreach ( $this->admin_notices->{$type} as $admin_notice ) {

            $dismiss_url = add_query_arg( array(
                'myplugin_dismiss' => $admin_notice->dismiss_option
            ), admin_url() );

            if ( ! get_option( "myplugin_dismissed_{$admin_notice->dismiss_option}" ) ) {
                ?><div
                    class="notice myplugin-notice notice-<?php echo $type;

                    if ( $admin_notice->dismiss_option ) {
                        echo ' is-dismissible" data-dismiss-url="' . esc_url( $dismiss_url );
                    } ?>">

                    <h2><?php echo "My Plugin $type"; ?></h2>
                    <p><?php echo $admin_notice->message; ?></p>

                </div><?php
            }
        }
    }
}

Note how we show a notice only if the DB does not have a corresponding option named myplugin_dismissed_$dismiss_option. We’ll need a mechanism to set that DB option when the user clicks to dismiss the notice. We will do this from JavaScript, making a call with that option name as a GET parameter, so that we know to set the correct database option.

Notifying the backend from the admin front

When a notice is dismissible, we output a data-dismiss-url attribute in the HTML. We’ll use that from JavaScript to make a call to that URL:

/**
 * Admin code for dismissing notifications.
 *
 */
(function( $ ) {
    'use strict';
    $( function() {
        $( '.myplugin-notice' ).on( 'click', '.notice-dismiss', function( event, el ) {
            var $notice = $(this).parent('.notice.is-dismissible');
            var dismiss_url = $notice.attr('data-dismiss-url');
            if ( dismiss_url ) {
                $.get( dismiss_url );
            }
        });
    } );
})( jQuery );

Pretty standard stuff. When a dismissible notice from our plugin is clicked on its .notice-dismiss, get the dismiss_url and call it. I guess I could have used a fancy framework like VanillaJS for this, but I chose plain old jQuery instead :-p

Don’t forget the usual enqueue script shenanigans:

public function action_admin_enqueue_scripts() {
    wp_enqueue_script( 'jquery' );
    wp_enqueue_script(
        'myplugin-notify',
        plugins_url( 'assets/scripts/myplugin-notify.js', __FILE__ ),
        array( 'jquery' )
    );
}

and add this to our constructor so the enqueuing will actually happen:

add_action( 'admin_enqueue_scripts', array( &$this, 'action_admin_enqueue_scripts' ) );

Perfect. Our little piece of JavaScript code now lives in the WordPress admin screens.

Setting the DB option

Now we’ll just need to catch the request on the PHP side and set a database option. Let’s do this on the admin_init action:

add_action( 'admin_init', array( &$this, 'action_admin_init' ) );

…and this is the code that will set an option in the database. Note how the name is constructed from the GET parameter in the request URL.

public function action_admin_init() {
    $dismiss_option = filter_input( INPUT_GET, 'myplugin_dismiss', FILTER_SANITIZE_STRING );
    if ( is_string( $dismiss_option ) ) {
        update_option( "myplugin_dismissed_$dismiss_option", true );
        wp_die();
    }
}

Once the option is set, we can just let WordPress die. No need to return the admin interface to the browser, this is just a background AJAX call.

Cleaning up after ourselves

Unfortunately, even in this day and age, many developers don’t think it’s important to clean up the trash they leave in the poor user’s database. This is even more infuriating when you consider that it rarely takes more than a couple of lines of code. Don’t be that guy. Create an uninstall.php file with the following content.

<?php
if ( defined( 'WP_UNINSTALL_PLUGIN' ) ) {
    global $wpdb;
    $wpdb->query( 'DELETE FROM wp_options WHERE option_name LIKE "myplugin_dismissed_%";' );
}

We can exploit the fact that we know the common prefix of all the dismissal DB options, and thus we can delete them all in one go, circumventing the delete_option() function altogether:

Expecting the unexpected

If you put together all of the above you already have a nice way to show persistent dismissible admin notices. But why stop there? We can hack away some more and make sure that any runtime errors in our code show up as notices too.

Imagine a clueless user sees your theme not working. Which do you prefer? To have to explain to them how to enable debugging and how to find and send you the logs from the wordpress unix directory on the host? Or do you simply ask them to copy the error on the screen and send it to you via email?

Observe this little hack that takes advantage of the PHP error reporting mechanism:

public static function error_handler( $errno, $errstr, $errfile, $errline, $errcontext ) {
    if ( ! ( error_reporting() & $errno ) ) {
        // This error code is not included in error_reporting
        return;
    }

    $message = "errstr: $errstr, errfile: $errfile, errline: $errline, PHP: " . PHP_VERSION . " OS: " . PHP_OS;

    $self = self::get_instance();
    switch ($errno) {
        case E_USER_ERROR:
            $self->error( $message );
            break;

        case E_USER_WARNING:
            $self->warning( $message );
            break;

        case E_USER_NOTICE:
        default:
            $self->notice( $message );
            break;
    }

    // write to wp-content/debug.log if logging enabled
    error_log( $message );

    // Don't execute PHP internal error handler
    return true;
}

Notice how this method is static, since we’d like to be able to call it from both dynamic and static contexts. We can now hook it up to PHP’s error reporting like so:

set_error_handler( array( 'MyPlugin_Admin_Notices', 'error_handler' ) );

and after your code ends, unhook it so as not to interfere with the WordPress core or other components:

restore_error_handler();

This will pop your error handler from a stack and return to whatever error handling mechanism was there before. Make a habit of surrounding your function bodies with these two lines, and you will know that whatever happens, you’ll at least get a visible user-friendly and developer-friendly error message on the admin screens.

Putting it all together

JS

/**
 * Admin code for dismissing notifications.
 *
 */
(function( $ ) {
    'use strict';
    $( function() {
        $( '.myplugin-notice' ).on( 'click', '.notice-dismiss', function( event, el ) {

            var $notice = $(this).parent('.notice.is-dismissible');
            var dismiss_url = $notice.attr('data-dismiss-url');
            if ( dismiss_url ) {
                $.get( dismiss_url );
            }
        });
    } );
})( jQuery );

PHP

<?php
if ( defined( 'WP_UNINSTALL_PLUGIN' ) ) {
    global $wpdb;
    $wpdb->query( 'DELETE FROM wp_options WHERE option_name LIKE "/* @echo slugus */_dismissed_%";' );
}
<?php
// don't load directly
defined( 'ABSPATH' ) || die( '-1' );

if ( ! class_exists( 'MyPlugin_Admin_Notices' ) ) {

    class MyPlugin_Admin_Notices {

        private static $_instance;
        private $admin_notices;
        const TYPES = 'error,warning,info,success';

        private function __construct() {
            $this->admin_notices = new stdClass();
            foreach ( explode( ',', self::TYPES ) as $type ) {
                $this->admin_notices->{$type} = array();
            }
            add_action( 'admin_init', array( &$this, 'action_admin_init' ) );
            add_action( 'admin_notices', array( &$this, 'action_admin_notices' ) );
            add_action( 'admin_enqueue_scripts', array( &$this, 'action_admin_enqueue_scripts' ) );
        }

        public static function get_instance() {
            if ( ! ( self::$_instance instanceof self ) ) {
                self::$_instance = new self();
            }
            return self::$_instance;
        }

        public function action_admin_init() {
            $dismiss_option = filter_input( INPUT_GET, 'myplugin_dismiss', FILTER_SANITIZE_STRING );
            if ( is_string( $dismiss_option ) ) {
                update_option( "myplugin_dismissed_$dismiss_option", true );
                wp_die();
            }
        }

        public function action_admin_enqueue_scripts() {
            wp_enqueue_script( 'jquery' );
            wp_enqueue_script(
                'myplugin-notify',
                plugins_url( 'assets/scripts/myplugin-notify.js', __FILE__ ),
                array( 'jquery' )
            );
        }

        public function action_admin_notices() {
            foreach ( explode( ',', self::TYPES ) as $type ) {
                foreach ( $this->admin_notices->{$type} as $admin_notice ) {

                    $dismiss_url = add_query_arg( array(
                        'myplugin_dismiss' => $admin_notice->dismiss_option
                    ), admin_url() );

                    if ( ! get_option( "myplugin_dismissed_{$admin_notice->dismiss_option}" ) ) {
                        ?><div
                            class="notice myplugin-notice notice-<?php echo $type;

                            if ( $admin_notice->dismiss_option ) {
                                echo ' is-dismissible" data-dismiss-url="' . esc_url( $dismiss_url );
                            } ?>">

                            <h2><?php echo "My Plugin $type"; ?></h2>
                            <p><?php echo $admin_notice->message; ?></p>

                        </div><?php
                    }
                }
            }
        }

        public function error( $message, $dismiss_option = false ) {
            $this->notice( 'error', $message, $dismiss_option );
        }

        public function warning( $message, $dismiss_option = false ) {
            $this->notice( 'warning', $message, $dismiss_option );
        }

        public function success( $message, $dismiss_option = false ) {
            $this->notice( 'success', $message, $dismiss_option );
        }

        public function info( $message, $dismiss_option = false ) {
            $this->notice( 'info', $message, $dismiss_option );
        }

        private function notice( $type, $message, $dismiss_option ) {
            $notice = new stdClass();
            $notice->message = $message;
            $notice->dismiss_option = $dismiss_option;

            $this->admin_notices->{$type}[] = $notice;
        }

	public static function error_handler( $errno, $errstr, $errfile, $errline, $errcontext ) {
		if ( ! ( error_reporting() & $errno ) ) {
			// This error code is not included in error_reporting
			return;
		}

		$message = "errstr: $errstr, errfile: $errfile, errline: $errline, PHP: " . PHP_VERSION . " OS: " . PHP_OS;

		$self = self::get_instance();

		switch ($errno) {
			case E_USER_ERROR:
				$self->error( $message );
				break;

			case E_USER_WARNING:
				$self->warning( $message );
				break;

			case E_USER_NOTICE:
			default:
				$self->notice( $message );
				break;
		}

		// write to wp-content/debug.log if logging enabled
		error_log( $message );

		// Don't execute PHP internal error handler
		return true;
	}
    }
}

MyPlugin_Admin_Notices::get_instance();

Thanks for reading

Please comment on what you liked or didn’t like in my code. What would you have done differently? Can you spot any bugs?

Until next time. Dismissed!

Leave a Reply

Your email address will not be published. Required fields are marked *