WP_Bootstrap_Navawalker Updates and Unit Testing PHP

Covering days 16 through 20 and culminating in 1 month and 1/5th of the way through 100 days.

This week I had spent a substantial portion of my time working on a project I've supported for last 5+ years – the WP Bootstrap Navwalker.

I've used the walker since version 2 of Bootstrap and been a contributer all of BS3 lifespan. With Bootstrap 4 finally in a stable release (after around 3 years in development!) and I have been working through a substantial rework of the entire navwalker.

I've focused on a few things through the rebuild.

  • Simplifying the codebase and improving readability.
  • Better link modifier (disabled, header, divider) and icon handling.
  • Handling long standing issues that were difficult to fix or required a large rework in the previous iteration.

Simplifying The Codebase And Improving Readability

To simplify things I have focused on a few things.

Too many paths!

Reducing the number of possible paths in the start_lvl function has been the first focus. This function has grown to contain lots of conditionals that result in different processing or outputs. At one point in Scrutinizer I seen it say that this function had over 20000 paths!

Lots of paths result in code that becomes hard to follow so to help improve that I decided to externalize some parts of the code into their own functions.

Prime candidates were some decision logic that didn't need to be cluttering up the main thread. A single call with a well named function does a better job of explaining what is happening than the large conditional code blocks – even through those codeblocks are well commented.

Better Link Modifier and Icon Handling

Bootstrap navigation has always required the use of specific classes on the container, the link and the wrapper elements for the dropdown elements to function. WordPress provides a way to set classes for individual nav items in the Menu Editor UI.

In previous versions the Title Attribute input was used to handle special classes for modifiers and icons.

The complexity here is that most of the time Walkers add the classes to the wrapper element – in this walker that is an <li> – however some of the time you need those classes to get applied directly to the link.

Icon classes need to be placed separately from the wrapper and the link. They are added to their own <i> element with aria-hidden="true" so that screen readers don't try read an icon character.

$icon_classes = array(); // This array would contain valid icon classes.

// Join any icon classes plucked from $classes into a string.
$icon_class_string = join( ' ', $icon_classes );

/**
 * Initiate empty icon var, then if we have a string containing any
 * icon classes form the icon markup with an <i> element. This is
 * output inside of the item before the $title (the link text).
 */
$icon_html = '';
if ( ! empty( $icon_class_string ) ) {
	// append an <i> with the icon classes to what is output before links.
	$icon_html = '<i class="' . esc_attr( $icon_class_string ) . '" aria-hidden="true"></i> ';
}

To make this happen I needed to loop through arrays of classnames and strip specific classes from the main array and save them in a new array for later use. The $icon_classes array is filled with that function.

I decided to handle link modifiers and icon classes separately as the link modifiers are used for setting a typeflag for decision making later in the execution.

/**
 * Find any custom linkmod or icon classes and store in their holder
 * arrays then remove them from the main classes array.
 *
 * Supported linkmods: .disabled, .dropdown-header, .dropdown-divider
 * Supported iconsets: Font Awesome 4/5, Glypicons
 *
 * NOTE: This accepts the linkmod and icon arrays by reference.
 *
 * @since 4.0.0
 *
 * @param array   $classes      an array of classes currently assigned to the item.
 * @param array   $link_classes an array to hold linkmod classes. Passed by reference.
 * @param array   $icon_classes an array to hold icon classes. Passed by reference.
 * @param integer $depth        an integer holding current depth level.
 *
 * @return array  $classes      a maybe modified array of classnames.
 */
function seporate_linkmods_and_icons_from_classes( $classes, &$linkmod_classes, &$icon_classes, $depth ) {
	// Loop through $classes array to find linkmod or icon classes.
	foreach ( $classes as $key => $class ) {
		// If any special classes are found, store the class in it's
		// holder array and and unset the item from $classes.
		if ( preg_match( '/disabled/', $class ) ) {
			// Test for .disabled.
			$linkmod_classes[] = $class;
			unset( $classes[ $key ] );
		} elseif ( preg_match( '/dropdown-header|dropdown-divider/', $class ) && $depth > 0 ) {
			// Test for .dropdown-header or .dropdown-divider and a
			// depth greater than 0 - IE inside a dropdown.
			$linkmod_classes[] = $class;
			unset( $classes[ $key ] );
		} elseif ( preg_match( '/fa-(\S*)?|fas(\s?)|fa(\s?)/', $class ) ) {
			// Font Awesome.
			$icon_classes[] = $class;
			unset( $classes[ $key ] );
		} elseif ( preg_match( '/glyphicons-(\S*)?|glyphicons(\s?)/', $class ) ) {
			// Glyphicons.
			$icon_classes[] = $class;
			unset( $classes[ $key ] );
		}
	}

	return $classes;
}

Unit Testing My Code

When tests are successful and all is green 🙂

While I was going through the code and dealing with long standing issues I also took care of an issue which was opened not long ago on the early dev version of the v4 walker. The issue explained that the fallback method was broken and outputting nothing when it should have been outputting something.

The fix was easy – I had mistakenly used assign instead of append so swapping the operator on all of the markup to be output made it work.

// incorrect use:
$output = 'somehtmlcontent';
// fixed:
$output .= 'somehtmlcontent';

Once I'd fixed it I realised that the project has Unit Tests that can be run locally and are run by Travis but all they do is check that the file, class and methods exist. Those tests could be a lot more useful.

If there was a test in place that made sure the fallback output when it was supposed to and didn't when it wasn't I would have caught this mistake early and fixed it quick. Instead it escaped me till someone else pointed it out.

Testing That Functions Produce The Output They Should

The project has PHPUnit already available so all I had to do was add some tests that could check the output.

This fallback function should produce 2 different types of output. For a logged out user it should output nothing. If a user is logged in with edit_theme_options capability it should output markup with a link. There's also a flag that determines if it echos or returns.

I wrote some tests that do a few things:

  • Test that logged out users get empty strings and that both 'echo' and 'return' are the same.
  • Test that logged in users get html that looks to be valid and that 'echo' and 'return' are both the same.

When testing output of a function there are a few methods that PHPUnit has available. Those methods are useful but somewhat limited so many times – for output that may be complicated or subject to slight changes over time – you will want to check for traits of the valid output rather than the exact output to save you reworking tests all the time. PHPUnit already runs tests inside of a content buffer so all you need to do is get your output with ob_get_contents;.

One thing that got me stuck for a few minutes was how exactly could I get logged in output when this is run in CLI mode where no user is set. Turns out it's quite easy. Make a new user with the capability we need – I made an admin that does have all core capabilities – and set them to the current user.

// make an admin user and set it to be the current user.
$user_id = $this->factory->user->create( array( 'role' => 'administrator' ) );
wp_set_current_user( $user_id );

The full pair of test ended up coming out like this:

/**
 * Test Fallback method output for logged out users.
 *
 * Expects that for logged out users both echo and return requests should
 * produce empty strings.
 */
function test_fallback_function_output_loggedout() {

	// default is to echo reults, buffer.
	ob_start();
	WP_Bootstrap_Navwalker::fallback( $this->valid_sample_fallback_args );
	$fallback_output_echo = ob_get_clean();

	// empty string expected when not logged in.
	$this->assertEmpty(
		$fallback_output_echo,
		'Fallback output for logged out user created a non-empty string in echo mode.'
	);

	// set 'echo' to false and request the markup returned.
	$fallback_output_return = WP_Bootstrap_Navwalker::fallback( array_merge( $this->valid_sample_fallback_args, array(
		'echo' => false,
	) ) );

	// return and echo should result in the same values (both empty).
	$this->assertEquals(
		$fallback_output_echo,
		$fallback_output_return,
		'Fallback output for logged out user created a non-empty string in return mode.'
	);
}

/**
 * Test Fallback method output for logged in users.
 *
 * Expects strings to be produced with html markup and that they match when
 * requesting either a return or defaulting to echo.
 */
function test_fallback_function_output_loggedin() {

	// make an admin user and set it to be the current user.
	$user_id = $this->factory->user->create( array( 'role' => 'administrator' ) );
	wp_set_current_user( $user_id );

	// default is to echo results, buffer.
	ob_start();
	WP_Bootstrap_Navwalker::fallback( $this->valid_sample_fallback_args );
	$fallback_output_echo = ob_get_clean();

	// rudimentary content test - confirm it opens a div with 2 expected
	// values and ends by closing a div.
	$match = ( preg_match('/^(<div id="a_container_id" class="a_container_class">)(.*?)(<\/div>)$/', $fallback_output_echo ) ) ? true : false;
	$this->assertTrue(
		$match,
		'Fallback method seems to create unexpected html for logged in users in echo mode.'
	);

	// set 'echo' to false and request the markup returned.
	$fallback_output_return = WP_Bootstrap_Navwalker::fallback( array_merge( $this->valid_sample_fallback_args, array(
		'echo' => false,
	) ) );

	// return and echo should both produce the same strings.
	$this->assertEquals(
		$fallback_output_echo,
		$fallback_output_return,
		'Fallback method seems to create unexpected html for logged in users in return mode.'
	);
}

Testing Private Methods of a Class

When I was writing other tests I needed to call private methods and test their output. Private methods are not intended to be called from outside of the function so to make that happen we need to use a technique called Reflection.

We use a reflector to duplicate the class and methods we want and then set the methods to be publicly accessible from the reflector.

$wp_bootstrap_navwalker = $this->walker;

// since we're working with private methods we need to use a reflector.
$reflector = new ReflectionClass( 'WP_Bootstrap_Navwalker' );

// get a reflected method for the opener function and set to public.
$method = $reflector->getMethod( 'linkmod_element_open' );
$method->setAccessible( true );

Once you have the reflected method setup and accessible you can then use invokeArgs on the $method – passing the real class object followed by an array containing the args to pass to it.

// test openers for headers and dividers.
$header = $method->invokeArgs( $wp_bootstrap_navwalker, array( $this->valid_linkmod_typeflags[0], 'stringOfAttributes' ) );
$this->assertNotEmpty( $header, 'Got empty string for opener of ' . $this->valid_linkmod_typeflags[0] );

Developer Log: Best-Reloaded Theme Update

I've been thinking that I should write some development logs for some work that I do because it may be useful for others. Plus it gets me to writing more which is something I'm trying my hardest to make a habit.

This log is about some updates I'm making to a theme I have hosted in the .org theme repo.

Best Reloaded in the WordPress Theme Directory

Bootstrap Header Nav Improvements

This theme uses Bootstrap 4 for a framework. It has a top navigation bar with a menu using the navwalker class that I help maintain. It also has a search bar and is styled with a custom theme specific colored button.

The search bar is always on, first I want to make it possible to turn it off if users do not want it. Then I plan to offer color choice selections.

Adding an on/off toggle to theme options. This is easy. A checkbox in the customizer and a test for it's value at page generation.

A Checkbox On/Off Toggle In Customizer

Start with adding a section for the header nav options.

$wp_customize->add_section( 'best_reloaded_navbar', array(
	'title' => __( 'Header Navbar', 'best-reloaded' ),
	'priority' => 100,
) );

Then add a control and a setting for the checkbox.

$wp_customize->add_setting( 'display_navbar_search', array(
	'default' => 1,
	'sanitize_callback' => 'best_reloaded_sanitize_checkbox',
) );
$wp_customize->add_control( 'display_navbar_search', array(
	'label' => __( 'Toggle on/off the navbar search form. Checked = on', 'best-reloaded' ),
	'section' => 'best_reloaded_navbar',
	'settings' => 'display_navbar_search',
	'type' => 'checkbox',
) );

This uses a custom sanitization callback that simply checks value is either 1 or 0 – TRUE or FALSE.

/**
 * Sanitization for checkbox input
 *
 * @param booleen $input	we either have a value or it's empty to depeict
 *                       	a checkbox state.
 * @return booleen $output
 */
function best_reloaded_sanitize_checkbox( $input ) {
	if ( $input ) {
		$output = true;
	} else {
		$output = false;
	}
	return $output;
}

The final part of this is testing the value of the option and outputting the search form when it's set to 'on'.

// if the navbar search is on then output search form.
if ( get_theme_mod( 'display_navbar_search', true ) ) {
	get_search_form();
}

This is a screenshot of it in action in the customizer.

Navbar Brand Options

Next thing I was wanting to add was the ability to add a small branding icon to the navbar. Bootstrap has some styles and classes that allow this so let's look at what we need for this.

  • An On/Off toggle for navbar brand.
  • Option to select an image from media library
  • Checkbox to include the site title as text.

This time there are 3 options to add to the customizer. It's 2 checkboxes again and another for image upload. Sanitization for an image upload is a little different than with checkboxes.

Sanitizing Values With Image Uploads

When it comes to sanitizing the values from image uploads what you are actually working with is text strings. Urls in fact.

You get a string with the url to the file. First you want to check that you have a valid extension for the file it points to. WP has a function to do this – wp_check_filetype()

Once you're sure it's the right filetype then you can escape it as a url at return.

/**
 * Santization for image uploads.
 *
 * @param  string $input	This should be a direct url to an image file..
 * @return string          	Return an excaped url to a file.
 */
function best_reloaded_sanitize_image( $input ) {

	// allowed file types.
	$mimes = array(
		'jpg|jpeg|jpe' => 'image/jpeg',
		'gif'          => 'image/gif',
		'png'          => 'image/png',
	);

	// check file type from file name.
	$file_ext = wp_check_filetype( $input, $mimes );
	// if filetype matches the allowed types set above then cast to output,
	// otherwise pass empty string.
	$output = ( $file_ext['ext'] ? $input : '' );
	// if file has a valid mime type return it as valud url.
	return esc_url_raw( $output );
}

Controls and Settings for Branding Options and Image Upload

There's 3 sets of controls and settings here for each of the options we need set above. The most complicated one is the image upload control as it's building it's control from the class of a core control. It's a little more complicated to look at but works essentially the same.

// on/off toggle.
$wp_customize->add_setting( 'display_navbar_brand', array(
	'default' => 0,
	'sanitize_callback' => 'best_reloaded_sanitize_checkbox',
) );
$wp_customize->add_control( 'display_navbar_brand', array(
	'label' => __( 'Enable the navbar branding options which can be a small image and the site-title.', 'best-reloaded' ),
	'section' => 'best_reloaded_navbar',
	'settings' => 'display_navbar_brand',
	'type' => 'checkbox',
) );

// brand image.
$wp_customize->add_setting( 'brand_image', array(
	'default' => '',
	'sanitize_callback' => 'best_reloaded_sanitize_image',
) );
$wp_customize->add_control(
	new WP_Customize_Image_Control(
		$wp_customize,
		'brand_image',
		array(
			'label'      => __( 'Add a brand image to the navbar.', 'best-reloaded' ),
			'section'    => 'best_reloaded_navbar',
			'settings'   => 'brand_image',
			'description' => __( 'Choose an image to use for brancd image in navbar. Leave empty for no image.', 'best-reloaded' ),
		)
	)
);

/ toggle text on/off in brand.
$wp_customize->add_setting( 'display_brand_text', array(
	'default' => 0,
	'sanitize_callback' => 'best_reloaded_sanitize_checkbox',
) );
$wp_customize->add_control( 'display_brand_text', array(
	'label' => __( 'Select the checkbox to display the site title in the navbar as brand text.', 'best-reloaded' ),
	'section' => 'best_reloaded_navbar',
	'settings' => 'display_brand_text',
	'type' => 'checkbox',
) );

Outputting Navbar Brand in a Bootstrap Theme

Now at this point I realised that output would be slightly more complicated than just echoing values. I also spotted that very long titles could break layout of navbar quite easily so I needed to account for that.

When the brand is turned on you can output 3 things.

  • The Brand Image
  • The Site Title
  • Brand Image + Site Title

Some logic for deciding what is output is needed at runtime so instead of echoing values to in the template file I added an action hook instead. The hook will trigger, check if we should output a brand, try to build the brand and then ultimately output it if we have a brand to use.

The Hook & Action

The hook is a standard action hook for WP.

/**
 * Fires the navbar-brand action hook.
 *
 * @since 1.2.0
 */
function best_reloaded_do_navbar_brand() {
	/**
	 * Used to output whatever featured content is desired in for the navbar brand.
	 */
	do_action( 'best_reloaded_do_navbar_brand' );
}

The action calls a function to perform the output logic and stores the value. It then tests if it has a value, sanitizes it against a list of accepted html tags and attributes then echoes it to the page.

/**
 * Echos the markup output by navbar branding function.
 *
 * @return void
 */
function best_reloaded_output_navbar_brand() {
	// try get the branding markup.
	$output = best_reloaded_navbar_branding();
	// if we have output to use then sanitize and echo it.
	if ( $output ) {
		$allowed_brand_tags = array(
			'span' => array(
				'class' => array(),
			),
			'img' => array(
				'id'	=> array(),
				'class'	=> array(),
				'src' => array(),
				'alt' => array(),
				'width' => array(),
				'height' => array(),
				'style' => array(),
			),
		);
		echo wp_kses( apply_filters( 'best_reloaded_filter_navbar_brand', best_reloaded_navbar_branding() ), $allowed_brand_tags );
	}
}
add_action( 'best_reloaded_do_navbar_brand', 'best_reloaded_output_navbar_brand' );

Function to Generate Navbar Brand Markup

The function that generates the markup also handles the logic of what is output and deals with the issue of long titles breaking things.

I added a character cap by default of 30 chars and another customizer option for an override to allow long titles if the site owner wants to.

$wp_customize->add_setting( 'allow_long_brand', array(
	'default' => 0,
	'sanitize_callback' => 'best_reloaded_sanitize_checkbox',
) );
$wp_customize->add_control( 'allow_long_brand', array(
	'label' => __( 'Very long titles break the default navbar layout, if you want to allow very long titles here then check this box. NOTE: You can also turn off the search form for more space.', 'best-reloaded' ),
	'section' => 'best_reloaded_navbar',
	'settings' => 'allow_long_brand',
	'type' => 'checkbox',
) );

The function that returns the markup looks like this:

/**
 * Builds out a .navbar-brand based on options set in the theme.
 *
 * @return string containing html markup for brand
 */
function best_reloaded_navbar_branding() {
	// initial value for the output is false.
	$brand_output = false;
	// check for image set in theme options theme options.
	$brand_image = get_theme_mod( 'brand_image', '' );
	// Did we get an image or is the brand text turned on?
	if ( $brand_image || get_theme_mod( 'display_brand_text', false ) ) {
		// since we have at least 1 of the items then start the output.
		$brand_output = '<span class="h1 navbar-brand mb-0">';
		if ( $brand_image ) {
			// we have an image.
			$brand_output .= '<img id="brand-img" class="d-inline-block align-top mr-2" src="' . esc_url( $brand_image ) . '" >';
		}
		if ( get_theme_mod( 'display_brand_text' ) ) {
			// text is toggled on, get site title.
			$site_title = get_bloginfo( 'name', 'display' );
			// very long site titles break the navbar so cap it at a generous 50 chars.
			if ( strlen( $site_title ) <= 50 || get_theme_mod( 'allow_long_brand', false ) ) {
				$brand_output .= esc_html( $site_title );
			}
		}
		$brand_output .= '</span>';
	}
	// this will return the markup if we have any or it will return false.
	return $brand_output;
}

Next Steps

Now that this works and I've tested it I will push the update to the .org repo and think about my next set of tweaks and changes.