Workflow 2.0: Making it easier

Let’s talk about the rest of wp-admin.

Add custom dashboard widgets

Wouldn’t it be nice if, when our users logged in, they could see a tidy little list of everything they need to do, and nothing they don’t need to do?

We can make that happen with some dashboard cleanup and some custom widgets.

First, let’s hide admin notices for everyone who’s not an Administrator:

add_action('admin_head', 'workflow_2_hide_notices');
function workflow_2_hide_notices() {
	if(!current_user_can('manage_options')) {
		echo '<style type="text/css">.notice{display:none;}</style>';
	}
}

Next, let’s remove some of the dashboard widgets and replace them with our own custom ones:

add_action('wp_dashboard_setup', 'workflow_2_add_overdue_widget');
// "Overdue" - shown to all users since all users can edit
function workflow_2_policy_add_overdue_widget() {
	wp_add_dashboard_widget(
		'workflow_2_overdue_widget',
		'Policies that need updates',
		'workflow_2_overdue_widget_content'
	);
	// Only show "ready for review" widget to users who can publish
	$post_types = get_post_types('', 'names');
	$publish = 0;
	foreach($post_types as $type) {
		$permission = 'publish_' . $type . 's';
		if(current_user_can($permission)) { $publish++; }
	}
	if($publish > 0) {
		wp_add_dashboard_widget(
			'workflow_2_review_widget',
			'Policies ready for your review',
			'workflow_2_review_widget_content'
		);
	}
	remove_meta_box('dashboard_incoming_links', 'dashboard', 'normal');
	remove_meta_box('dashboard_plugins', 'dashboard', 'normal');
	remove_meta_box('dashboard_primary', 'dashboard', 'side');
	remove_meta_box('dashboard_secondary', 'dashboard', 'normal');
	remove_meta_box('dashboard_quick_press', 'dashboard', 'side');
	remove_meta_box('dashboard_recent_drafts', 'dashboard', 'side');
	remove_meta_box('dashboard_recent_comments', 'dashboard', 'normal');
	remove_meta_box('dashboard_right_now', 'dashboard', 'normal');
	remove_meta_box('dashboard_activity', 'dashboard', 'normal');
	remove_action('welcome_panel', 'wp_welcome_panel');
}

Here’s the code that actually creates the “overdue” widget. It always displays a reminder message that each policy needs review once a year. Then, it loops through all the posts on the site (caveat for sites with a ton of posts: this could be an issue), checks to see if they have one of our custom pieces of postmeta – this means it’s a policy and not a Post or Page, but without us having to manually specify all 20-odd custom post types – and then if the last-modified date is more than 9 months ago and the current user has “edit” capability, it displays a link to edit the policy, including text that explains when it is due for review (1 year after the last-modified date).

function workflow_2_overdue_widget_content() {
	echo "<p>Each policy should be reviewed at least once a year. If the policy has no updates, please 'edit' it anyway and Update or Submit for Review. This will change its timestamp so you won't receive email reminders to update your policy.</p>";
	// Get all posts
	$posts = get_posts(array(
		'numberposts' => -1,
		'post_type' => 'any'
	));
	$x=0;
	for($i=0; $i<count($posts); $i++) {
		$post_id = $posts[$i]->ID;
		$policy_type = get_post_meta($post_id, 'policy_type', true);
		// Only include posts with "policy_type" postmeta (required field for all CPTs. Prevents pulling Posts or Pages.)
		if(!empty($policy_type)) {
			$post_type = $posts[$i]->post_type;
			$lastModified = strtotime($posts[$i]->post_modified);
			$now = strtotime('now');
			// if the last-modified date was more than 9 months ago
			if($lastModified - 283824000 > $now) {
				// if the current user can edit the policy, show details
				$permission = 'edit_others_' . $post_type . 's';
				if(current_user_can($permission)) {
					$reviewdate = gmdate('M Y', ($lastModified + 31557600));
					$posttitle = $posts[$i]->post_title;
					$postlink = get_edit_post_link($post_id);
					echo "<a href=\"$postlink\">$posttitle</a> - due $reviewdate<br/>";
					$x++;
				}
			}
		}
	}
	if($x == 0) {
		echo "<p>You're all caught up!</p>";
	}
}

The widget for Policy Approvers is slightly more complex, as it displays both new pending policies and pending revisions.

First, we check for any policy with a “pending” status. Because we’ve already set up a workflow to prevent published policies from going back to pending status, in our case, any policy that is currently “pending” is a new one created by a Policy Editor. Again, we list a link to each policy in this category.

Next, we check for revised policies. This is more complicated because we can’t rely on a “pending” status. So, we first get all published CPTs. (Again, this could be an issue on sites with a lot of posts, and on some sites, you might not want to single out all CPTs – so ultimately, if we had checkboxes in Core to turn on a workflow, it would be better to pull all posts within just the post types where those checkboxes were checked. Next, we pull all revisions (again, potential performance issue) so we can compare the revisions to the published posts.

Looping through the published posts, we first check whether the current user has “publish” capability. If so, we then loop through the revisions to find all revisions of the current post. Because we’ve ordered them by ID, in descending order, the first revision will always be a copy of the published post. The second revision is the one we’re interested in. If it was modified after the currently-published post, that means a Policy Editor has made changes that haven’t been reviewed, so the link to them needs to appear here. But in this case, instead of linking to the Edit screen, we link to the Revision Comparison screen so it’s very easy to tell exactly what changed between the edited version and the last-published version.

function workflow_2_review_widget_content() {
	// New policies
	?><h3 style="color:#036;font-weight:bold;">New Policies</h3><?php
	// Get posts
	$posts = get_posts(array(
		'numberposts' => -1,
		'post_type' => 'any',
		'post_status' => 'pending'
	));
	if(count($posts) > 0) {
		for($i=0; $i<count($posts); $i++) {
			$post_type = $posts[$i]->post_type;
			$capability = 'publish_' . $post_type . 's';
			if(current_user_can($capability)) {
				$posttitle = $posts[$i]->post_title;
				$post_id = $posts[$i]->ID;
				$postlink = get_edit_post_link($post_id);
				echo "<a href=\"$postlink\">$posttitle</a><br/>";
			}
		}
	} else {
		echo '<p>None to review.</p>';
	}
	// Revised policies
	?><br/><hr/><br/><h3 style="color:#036;font-weight:bold;">Revised Policies</h3><?php
	global $wpdb;
	$displayedPolicies = 0;
	// Get all published CPTs - lowest IDs first
	$publishedPosts = $wpdb->get_results("SELECT ID, post_title, post_modified, post_type FROM wp_posts WHERE post_status = 'publish' AND post_type NOT in ('attachment','nav_menu_item','post','page') ORDER BY ID asc");
	// Get all CPT revisions - highest IDs first
	$revisionPosts = $wpdb->get_results("SELECT ID, post_title, post_modified, post_parent FROM wp_posts WHERE post_status = 'inherit' AND post_type NOT in ('attachment','nav_menu_item','post','page') ORDER BY ID desc");
	// Only display revisions the current user can publish
	for($i=0; $i<count($publishedPosts); $i++) {
		$capability = 'publish_' . $publishedPosts[$i]->post_type . 's';
		if(current_user_can($capability)) {
			// Loop through revisions to find the second one whose parent is the current ID.
			$y = 0;
			for($x=0; $x<count($revisionPosts); $x++) {
				// If the revision is a child of the current published post in the loop:
				if(($revisionPosts[$x]->post_parent) == ($publishedPosts[$i]->ID)) {
					// When we find the latest revision, Y will be 0. Add 1, so that when we find the next-to-latest revision (the actual PE edits), Y will be 1 and we can grab it by its ID.
					if($y == 1) {
						$latestRevision = $revisionPosts[$x];
						$publishedPost = $publishedPosts[$i];
						// Finally, check whether the revision is newer than the published post. If so, it's a Policy Editor revision ready for review, so display a link.
						$published_date = strtotime(get_post_meta($publishedPost->ID, 'last_published', true));
						$pe_date = strtotime($latestRevision->post_modified);
						if($pe_date > $published_date) {
							echo '<a href="/wp-admin/revision.php?revision=' . $latestRevision->ID . '&gutenberg=true">' . $latestRevision->post_title . '</a>';
							// This counter increments when a policy is displayed. That way if none are displayed for the current user, we can present a none-available message.
							$displayedPolicies++;
						}
						break;
					}
					$y++;
				}
			}
		}
	}
	if($displayedPolicies == 0) {
		echo '<p>None to review.</p>';
	}
}

Add custom post listing columns

Wouldn’t it also be nice if, when a user logged in and viewed the listing of posts within their CPT, they could see at a glance which ones they will need to look at soon? It’s fairly easy to add columns. Here, we’ll add one column that shows a link to the revision if any Policy Editor has submitted edits for review, and another column that highlights policies that need changes or will need them soon. It’s a little more manual – you have to use both a filter hook and an action hook for every single CPT, but worth the effort.

Here are the filter and action calls:

add_filter('manage_aap_posts_columns', 'workflow_2_add_review_by_column');
add_action('manage_aap_posts_custom_column' , 'workflow_2_add_listing_col_data', 10, 2);

Replace “aap” with your custom post type – so if your CPT is a “portfolio,” you would instead use

add_filter('manage_portfolio_posts_columns', 'workflow_2_add_review_by_column');
add_action('manage_portfolio_posts_custom_column' , 'workflow_2_add_listing_col_data', 10, 2);

Just to keep things tidy and easy for less-WP-savvy users, in addition to adding the “Review By” column, we’re also removing a few Yoast SEO columns and a couple of Core columns.

function workflow_2_add_listing_cols($columns) {
	// First, remove Yoast SEO columns
	unset($columns['wpseo-linked']);
	unset($columns['wpseo-links']);
	unset($columns['wpseo-score']);
	// Next, remove Date and Author Core columns
	unset($columns['date']);
	unset($columns['author']);
	// Finally, add our custom "Pending Changes" and "Review By" columns
	$columns['ispending'] = 'Pending Changes';
	$columns['nextreview'] = 'Review By';
	return $columns;
}

Finally, we need to populate our new column with data – this is the function we hooked to the action hook.

function workflow_2_add_listing_col_data($column, $post_id) {
	// Only in the "Pending Changes" column
	if($column == 'ispending') {
		// If there is any revision with a higher ID than the "last_pub_id" postmeta, then it is a revision awaiting review, so show a link
		$last_pub_id = get_post_meta($post_id, 'last_pub_id', true);
		global $wpdb;
		$revisionPosts = $wpdb->get_results("SELECT ID, post_title, post_author, post_modified FROM wp_posts WHERE post_parent = \"" . $post_id . "\" AND post_type != 'attachment' AND post_name NOT LIKE '%autosave%' AND ID > " . $last_pub_id . " ORDER BY ID desc");
		if(count($revisionPosts) > 0) {
			$php_date = strtotime($revisionPosts[0]->post_modified);
			$author = get_author_name($revisionPosts[0]->post_author);
			echo '<a href="' . admin_url('revision.php?revision=' . $revisionPosts[0]->ID . '&gutenberg=true') . '">Edits submitted ' . date('m-d-Y', $php_date) . ' by ' . $author . '</a><br>';
		}
	// Only in the "Next Review" column
	} elseif($column == 'nextreview') {
		// see whether we have revisions
		global $wpdb;
		$revisionPosts = $wpdb->get_results("SELECT post_modified FROM wp_posts WHERE post_parent = $post_id AND post_status = 'inherit' AND post_type NOT in ('attachment','nav_menu_item','post','page') ORDER BY ID desc");
		// if not, get info from the original published post
		if(count($revisionPosts) == 0) {
			$revisionPosts = $wpdb->get_results("SELECT post_modified FROM wp_posts WHERE ID = $post_id");
		}
		$lastreviewed = new DateTime($revisionPosts[0]->post_modified);
		$now = new DateTime('now');
		$oneyearreview = new DateTime($revisionPosts[0]->post_modified);
			date_add($oneyearreview, date_interval_create_from_date_string('1 year'));
		$threemonthsfuture = new DateTime('now');
			date_add($threemonthsfuture, date_interval_create_from_date_string('3 months'));
		echo '<span style="padding:.5em;';
		// if it's been modified within 1 year
		if($oneyearreview > $now) {
			// if it will need review within 3 months, make it yellow
			if($oneyearreview < $threemonthsfuture) {
				echo ' background-color:yellow;';
			}
		}
		// else it hasn't been modified within 1 year, so turn it red
		else {
			echo ' background-color:red; color:white;'; 
		}
		echo '">' . $oneyearreview->format('M. Y') . '</span>';
	}
}

First, for the “Pending Changes” column, we check to see if there is any revision with a higher ID than the last_pub_id stored in postmeta. If so, we output a link to the latest revision, with the link text being “Edits submitted (date) by (author)”. This helps not just the Policy Approvers, but also serves as visual confirmation to the Policy Editors that their changes have indeed been saved and are awaiting review.

Next, for the “Review By” column, we use a bit of inline styling. (I know, I know, I don’t love it, but it also doesn’t seem like it’s worth enqueuing a separate CSS file just for this.) The styles highlight the date in red if the policy hasn’t been modified in over a year, or yellow if it’s been between 9 months and a year since it was last modified. In our case, if a policy doesn’t need changes, the Policy Editor still needs to “submit changes” so the Policy Approver can “approve those changes” – meaning the database saves a revision that has the same data as the published policy, but with an updated date. This way, we can also see how often someone has formally reviewed the policy, even if no changes were needed.

Leave a Reply

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