Workflow 2.0: creating the workflow

The vision for how the workflow will work hasn’t changed from 1.0 to 2.0. We still have the same goals.

In fact, step 1 of creating our workflow is nearly the same. When we register our CPT, only 1 line of code is different – but there are a few other tweaks specific to version 2.0. See the article Workflow 2.0: creating the CPTs and roles.

The next step, putting the actual workflow into place, has to change more for a couple of reasons. One reason: Gutenberg has been merged into WP Core, so we are now dealing with a Block Editor. (Yes, it would be possible to slap the Classic Editor plugin on as a band-aid, but we’d like to unlock the benefits of the Block Editor.) The other reason: we’d like to improve the code. We’re taking out the admittedly bad code that locks users out of wp-admin if their JavaScript is disabled, we’re taking out our script that manually inserts posts instead of letting WP Core put them in, and we’re solving the problems with versioned postmeta.

I’m very hopeful that revealing how I have achieved these goals will help others who are beginning, or will soon begin, to think about how to integrate workflows into WordPress Core. Again, I’m not saying this code is perfect. In fact, I’m hoping it will expose some of the stumbling blocks which hopefully the Core team can help whittle down, and help everyone reach the finish line a little bit sooner and with a little bit better finished project.

So, here we go again.

Quick Edit

I tried to make Quick Edit possible, but it works so differently from the regular editing workflow, there were two main snags.

Snag #1: in order to preserve postmeta being saved with every revision, you have to manually add a column to the post listing screen for every piece of postmeta. Then, you also have to manually add labels and fields for each one into the Quick Edit screen. These parts can be accomplished, but they make things pretty clunky. I also don’t see a way around hard-coding each piece of postmeta and its type – WordPress can’t tell from a field value what all the options should be, so you’re stuck deciding whether to display the options as radio buttons, checkboxes, select, textarea – and if it’s radio buttons, checkboxes, or select, you also have to populate all the possible options. So basically, you are adding them once to the main post editing screen and a second time to the Quick Edit screen.

Snag #2: Quick Edit doesn’t use the same process to save data as regular, full-blown Edit. This was what really prevented me from pursuing the matter further. If a user has publishing rights, Quick Edit runs the `save_post` hook just one time – versus Edit’s three times – and saves all the data directly to the published post. It doesn’t even create a new revision! This means that we wouldn’t have a log of every change, which is what we’re trying to achieve by building this workflow.

See code to disable Quick Edit back in version 1.

Postmeta revisions

As a quick refresher, WP Core does not save postmeta revisions. In version 1.0, we used a plugin called WP-Post-Meta-Revisions, which works with WordPress 4.x and 5.x, but didn’t quite do everything we wanted. Also, we were using Advanced Custom Fields to set up the postmeta fields. With the desire to roll our own simplified solution, after a great deal of trial and error, I came up with something that works.

Adding the meta boxes to the editor screen works in both the Classic Editor and the Block Editor. So you can follow along, here is one of the fields I set up:

Create the postmeta

/* Meta boxes / postmeta */
add_action("add_meta_boxes", "workflow_2_add_post_meta_boxes");
function workflow_2_add_post_meta_boxes() {
	/* Get all post types, so our meta box is added to them all */
	$types = get_post_types();
	add_meta_box("workflow-2-policy-approvers", "Approvers", "workflow_2_add_approvers_meta", $types, "side", "high", null);
}
/* Approvers show meta input */
function workflow_2_add_approvers_meta( $post ) {
	wp_nonce_field( 'policy_approvers_nonce', 'policy_approvers_nonce' );
	$value = get_post_meta( $post->ID, 'policy_approvers', true );
	echo '<select style="width:100%" id="policy_approvers" name="policy_approvers">';
		echo '<option value="Academic Council"';
			if($value == 'Academic Council') { echo ' selected'; }
			echo '>Academic Council</option>';
		echo '<option value="Board of Trustees"';
			if($value == 'Board of Trustees') { echo ' selected'; }
			echo '>Board of Trustees</option>';
		echo '<option value="Executive Council"';
			if($value == 'Executive Council' || empty($value)) { echo ' selected'; }
			echo '>Executive Council</option>';
		echo '<option value="Office of the President"';
			if($value == 'Office of the President') { echo ' selected'; }
			echo '>Office of the President</option>';
		echo '<option value="Student Development Council"';
			if($value == 'Student Development Council') { echo ' selected'; }
			echo '>Student Development Council</option>';
	echo '</select>';
}
/* Approvers save postmeta */
add_action( 'save_post', 'workflow_2_save_approvers_meta' );
function workflow_2_save_approvers_meta( $post_id ) {
	if ( ! isset( $_POST['policy_approvers_nonce'] ) ) {
		return;
	}
	if ( ! wp_verify_nonce( $_POST['policy_approvers_nonce'], 'policy_approvers_nonce' ) ) {
		return;
	}
	if ( ! isset( $_POST['policy_approvers'] ) ) {
		return;
	}
	$my_data = sanitize_text_field( $_POST['policy_approvers'] );
	update_post_meta( $post_id, 'policy_approvers', $my_data );
}

You can have other sorts of fields, like text inputs or radio buttons, but for our purposes a dropdown was simplest. We have additional postmeta fields, but I wanted to provide the simplest example possible (and not give away all aspects of our secret sauce). 🙂

Save postmeta revisions

This is where we start to veer off of the True WordPress Way, but believe me when I say I tried everything I could to find some other way that would successfully save the right postmeta to each and every post revision. This is one of the areas I’m most anxious to hear from others on – if there’s already another way, I’d love to know about it, and if there isn’t, then this is an area I would like the WP Core team to really focus on when they tackle workflow. Unfortunately, with the Block Editor’s release, the Core team stance seems to be “use postmeta if you must, but we don’t think you’ll need it in the future.” I respectfully disagree.

You see, postmeta is useful in a few ways. For one, by having separate structured data (much like Tags or Categories) you can easily query a subset of posts. For another, when we display a single policy, it’s easy to set up a template that will visually display the various postmeta at the top of the post so visitors can always find the same type of information in the same place and with the same formatting. Because this is structured data that’s templated, if we decide to change the visual presentation, we can do it in the template itself, and hey presto, all of our policies are updated. If instead this information is all saved as post content, it becomes a huge undertaking, because every policy must be individually updated.

Now, WordPress does have custom taxonomies, so it would be possible to use one of these instead of postmeta. If you’re querying a subset of posts, a taxonomy query is much faster than a meta query. But for other postmeta – let’s take Yoast SEO’s fields, for example – custom taxonomies don’t provide a very user-friendly way to input the right sorts of data. I don’t think those postmeta fields are going anywhere, and I don’t see any reason for our policies to not have their structured data set up as postmeta as well.

With that opinion out of the way, here’s the only way I could find to successfully save my specific subset of postmeta with each and every revision.

add_action( 'save_post', 'workflow_2_save_postmeta_revisions', 10, 3 );
function workflow_2_save_postmeta_revisions( $post_id, $post, $update ) {
	global $_POST;
	// If this is the last run of save_post ('update' is 1 and $_POST array is not empty)
	if(count($_POST) > 0 && $update == 1) {
		// Identify the parent post
		if($post->post_parent == 0) {
			$parent_id = $post_id;
		} else {
			$parent_id = $post->post_parent;
		}
		// Get revisions
		$revisions = wp_get_post_revisions($parent_id);
		// Loop through them to find any that do not have postmeta
		$saved = 0;
		foreach($revisions as $revision) {
			$revision_meta = get_post_meta($revision->ID);
			// If this is the first (newer) revision that does not have postmeta, save the $_POST postmeta to it
			if($saved == 0 && empty($revision_meta['policy_approvers'])) {
				add_metadata('post', $revision->ID, 'policy_approvers', $_POST['policy_approvers'], true);
				$saved++;
			// If this is the second (older) revision that does not have postmeta, delete it
			} elseif($saved == 1 && empty($revision_meta['policy_type'])) {
				wp_delete_post($revision->ID, true); // true means bypass the trash and fully force delete
				// Stop looping through revisions - we're done
				break;
			}
		}
	}
}

This could use some optimization; here I am explicitly only saving the “policy_approvers” postmeta. The full code explicitly saves our other postmeta fields. I am fairly sure it would be possible to loop through the retrieved postmeta and save all those fields, making the function dynamic so that future users wouldn’t have to edit the code just to have it save revisions of additional postmeta fields.

It’s also not ideal that WordPress automatically creates two revisions on this step, and I’m manually deleting one here. I tried to find a way to prevent the second revision from being added at all, but didn’t succeed.

Believe me when I say I tested several hooks (transition_post_status, post_updated, save_post, pre_post_update) very thoroughly to finally arrive at a condition where I could capture the right version of the postmeta and save it, associated with the correct revision ID. WP’s own documentation advises plugin developers not to use the low-level “add_metadata” function, but because “update_post_meta” and “add_post_meta” save the meta associated to the parent and not the revision, it was the only way I could find (short of running a custom MySQL query) to accomplish the goal.

In fact, here’s a diagram of just some of the tests I ran with specific scenarios.

Diagram of the posts WordPress saves to the database during different hooks

My apologies to anyone who cannot clearly read this very complex diagram. I am aware that the image is not accessible, and I’m not sure how to properly make this complex information available in other formats. (If anyone has suggestions on that, I am also all ears!)

Show postmeta on the revisions screen

It’s not much good having all this data in the database and never exposing it to any of the users. Our users are intended to use the Revision Comparison screen to quickly see what within a policy has changed, so it made sense to add the postmeta there.

function workflow_2_show_meta_labels_in_revisions( $fields ) {
	$fields['policy_approvers'] = 'Approvers';
	return $fields;
}
add_filter( '_wp_post_revision_fields', 'workflow_2_show_meta_labels_in_revisions' );
function workflow_2_show_meta_data_in_revisions( $value, $field_name, $post ) {
	return get_metadata( 'post', $post->ID, $field_name, true);
}
add_action( 'admin_head', 'workflow_2_show_meta_data_in_admin_head' );
function workflow_2_show_meta_data_in_admin_head() {
	add_filter( '_wp_post_revision_field_policy_approvers', 'workflow_2_show_meta_data_in_revisions', 10, 3);
}

Again, this is simplified to show just one postmeta field, but you can add as many as you need. Just add to the $fields in the first function and add an additional filter in the last function.

Reverting postmeta when a post is reverted

It’s also possible for a user with “publish” capability to revert a post immediately to a certain revision. (This happens from the Revisions Comparison screen.) This reverts the post title and post content, but not the postmeta, so this code that will restore the revision’s postmeta (only the explicitly set postmeta keys, but you could make it dynamic) as well when this happens.

add_action( 'wp_restore_post_revision', 'workflow_2_restore_revision_meta', 10, 2 );
function workflow_2_restore_revision_meta( $post_id, $revision_id ) {
	$policy_approvers = get_metadata( 'post', $revision_id, 'policy_approvers', true );
	update_post_meta( $post_id, 'policy_approvers', $policy_approvers );
}

And now, for our feature presentation

The biggest change from Workflow 1.0 to Workflow 2.0 is, well, the workflow. Instead of hijacking the “Publish” button and sending $_POST data to our own script, we’re taking advantage of WP Core hooks that happen when a post is saved. I haven’t tested on WP 4.x because we’re working on Block Editor compatibility, but these hooks have also been around in Core for quite awhile, so this should also work with the Classic Editor.

So, we’re using WP hooks instead of our own little script, which is more like the WordPress Way. We’re still doing some possibly questionable things, and if you have any suggestions for ways to do things better, more dynamically, in a more extensible way, please reach out – I’m all ears.

Helper functions

Trying to keep the code DRY (Don’t Repeat Yourself), I have a few helper functions that can be called from whichever hook and whichever part of the if/else statements are currently running.

function workflow_2_get_to($can, $cannot) {
	$users = get_users();
	if(count($users) > 0) {
		foreach($users as $user) {
			if($user->allcaps["$can"] == 1 && $user->allcaps["$cannot"] != 1) {
				$to[] = $user->data->user_email;
			}
		}
	/* fallback: email the site admin if no appropriate users were found */
	} else {
		$to = get_bloginfo('admin_email');
	}
	return $to;
}
function workflow_2_send_single_policy_email($to, $subject, $message) {
	/* send the message - this is only a separate function because I actually use more detailed logic to determine whether or not to actually send the email. (If it's staging, only send if we're in test mode, that kind of thing. */
	wp_mail($to, $subject, $message);
}
function workflow_2_update_postmeta($post) {
	// Save last_pub_id postmeta (ID of the revision that is an exact copy of the parent right now)
	$revisions = wp_get_post_revisions($post->ID);
	$i=0;
	foreach($revisions as $revision) {
		$rev_id = $revision->ID;
		$i++;
		if($i==1) {
			break;
		}
	}
	update_post_meta($post->ID, 'last_pub_id', "$rev_id");
}

Reverting the published policy

When I mapped out all the hooks I thought would be most helpful – transition_post_status, save_post, and post_updated – I found that in each of the policy editing scenarios, the save_post hook is always the one that runs last. I wanted to make sure the data was correct at the very last step in the process.

If a Policy Approver republishes an existing policy, we use the “workflow_2_update_postmeta()” function above to add a “last_pub_id” postmeta to the parent. When a Policy Editor edits an existing policy, WP Core saves the Policy Editor’s edits and sets the post to “pending” status, which un-publishes it. My code here reverts the parent to the “last_pub_id” revision – the last approved and published version – so the public can continue to access the old version of the policy until the edits are approved. Then, email the Policy Approver so they know to come review the changes.

This function has priority 20, which means it fires after our postmeta-revision-saving function, ensuring that all revisions’ postmeta has already been saved.

add_action('save_post', 'workflow_2_revert_parent', 20, 3);
function workflow_2_revert_parent($post_id, $post, $update) {
	global $_POST;
	$last_published_id = get_post_meta($post->ID, 'last_pub_id', true);
	// If this is the last run of save_post ('update' is 1 and $_POST array is not empty) AND this is a policy CPT AND this is a PE edit (post_status is pending and last_pub_id exists)
	if(count($_POST) > 0 && $update == 1
		&& $post->post_type != 'post' && $post->post_type != 'page' && $post->post_type != 'attachment' && $post->post_type != 'nav_menu_item' && $post->post_type != 'customize_changeset'
		&& $post->post_status == 'pending' && !empty($last_published_id)
	) {
		// Get last published data
		$last_published_post = get_post($last_published_id);
		$last_published_meta = get_post_meta($last_published_id);
		// Revert the published post: title, content, last_modified, last_modified_gmt
		global $wpdb;
		$revert_post = $wpdb->get_results("UPDATE wp_posts SET
			post_status = \"publish\",
			post_title = \"" . esc_sql($last_published_post->post_title) . "\",
			post_content = \"" . esc_sql($last_published_post->post_content) . "\",
			post_modified = \"" . $last_published_post->post_modified . "\",
			post_modified_gmt = \"" . $last_published_post->post_modified_gmt . "\"
			WHERE ID = \"" . $post->ID . "\"
		");
		// Revert the published post's postmeta
		$revert_policy_approvers = $wpdb->get_results("UPDATE wp_postmeta SET
			meta_value = \"" . $last_published_meta['policy_approvers'][0] . "\"
			WHERE post_id = \"" . $post->ID . "\"
			AND meta_key =\"policy_approvers\"
		");
	}
}

Important note: take another look at lines 17 and 18. We don’t just set post_content to post_content – we use esc_sql(), a WordPress function that ensures special characters don’t throw anything off. Without this, if there are any double quotes in the post title or content, they’ll throw a PHP error – and that can cascade to the point that our post doesn’t revert, leaving us with a policy in “pending” status.

It would be fairly simple to update this to use whatever prefix is used on the site (i.e. instead of “wp_posts”, “{prefix}_posts”. We could also use wpdb->prepare() to make certain our query runs safely. What’s less simple is making the postmeta reversions dynamic.

Notifying by email

If a Policy Editor creates a new policy, we email the Policy Approvers. WP Core handles the rest – it automatically sets the status to pending.

If a Policy Approver publishes a Policy Editor’s new policy as-is, creates a new policy, or edits an existing policy, we email the Policy Editor as a courtesy. We also save the “last_pub_id” postmeta for future use.

In a long-term workflow solution, there would need to be options as to whether or not to trigger emails, and what their content would be. Our email notification needs are fairly simple, so they’re hard-coded here.

Once again, I’m using the save_post hook, and this function has priority 30 – so I know the postmeta is all correct and the policy has already been reverted to published status.

add_action('save_post', 'workflow_2_fire_emails', 30, 3);
function workflow_2_fire_emails($post_id, $post, $update) {
	global $_POST;
	// If this is the last run of save_post ('update' is 1 and $_POST array is not empty) and this is a policy CPT
	if(count($_POST) > 0 && $update == 1 && $post->post_type != 'post' && $post->post_type != 'page' && $post->post_type != 'attachment' && $post->post_type != 'nav_menu_item' && $post->post_type != 'customize_changeset') {
		// If current user can publish, PA has published (may be a new policy or edits)
		$capability = 'publish_' . $post->post_type . 's';
		$current_user = wp_get_current_user();
		$name = $current_user->display_name;
		if(current_user_can($capability)) {
			// Email PEs
			$post_type = $post->post_type;
			$can = 'edit_' . $post_type . 's';
			$cannot = 'publish_' . $post_type . 's';
			$to = stmu_get_to($can, $cannot);
			$subject = 'Policy Library: "' . $post->post_title . '" has been published';
			$permalink = get_permalink($post->ID);
			$message = $name . " published a policy in your area at $permalink .";
			stmu_send_single_policy_email($to, $subject, $message);
			// Update last_updated postmeta and Swiftype index
			stmu_update_postmeta_and_swiftpe($post);
		// If current user cannot publish, PE has submitted (may be a new policy or edits)
		} else {
			// Email PAs
			$post_type = $post->post_type;
			$can = 'publish_' . $post_type . 's';
			$cannot = 'manage_options';
			$to = stmu_get_to($can, $cannot);
			$subject = 'Policy Library: please review "' . $post->post_title . '"';
			$dashboardUrl = get_admin_url('', 'index.php');
			$message = $name . " has submitted a policy for your review.\n\n" . '1. Please log into the Policy Library ( ' . $dashboardUrl . " ), make edits if necessary, and publish the policy.";
			stmu_send_single_policy_email($to, $subject, $message);
		}
	}
}

Restricting access to revisions

One last kink in the works: the one Core role that can edit, but not publish, has access to revisions.

This means that by default, our custom Policy Editor role can peruse a published policy’s revisions, find one they like, and hit “Restore This Revision,” which immediately publishes that revision. Not what we want – someone who can’t publish shouldn’t be able to, well, publish!

This is because by default, the only role who can edit but not publish is a Contributor. And they can only edit their own, unpublished posts. This means:

  • If a Contributor creates a new post, then makes changes and creates a revision or two, they can access the revisions screen. Restoring a revision means that the parent post gets updated, but it was already in “pending” status before the Contributor restored a revision, and it’s still in “pending” status after they’ve restored the revision. In this way, access to revisions does not enable the ability to publish.
  • Once a Contributor’s post is published, the Contributor no longer has access to edit the post. Thus, they also no longer have access to the revisions screen, so there’s no chance of them publishing an old revision that way either.

However, our Policy Editor role does have the ability to edit published posts – we very much want them to. It’s just that once a post is published, if the Policy Editor goes back to edit a policy and notices the link to revisions, they can use that link to pull up the revisions screen. And because WP acknowledges their “edit published posts” capability, they also have the ability to use the “Restore This Revision” post. Unfortunately, that also means that because the parent post was previously in “publish” status, the restored revision is also restored to “publish” status, meaning our Policy Editor just publicly published changes without going through the approval process.

Ideally, when Core tackles workflow, my opinion is that users with “edit published” but not “publish” capability should be able to access the revision comparison screen. They should also be able to suggest restoring an old revision – but in this case, if they do not have “publish” capability, the old revision should be saved as a new revision just like any other Policy Editor changes, the workflow notification should be triggered, and the previously-published version should remain public.

If that’s not possible when workflow in Core launches, I would suggest that those users with “edit published” but not “publish” capability should be able to access the revision comparison screen and view the revisions, but there should either be no CTA button or else a disabled CTA button so that they simply cannot use the “Restore This Revision” capability. This will still grant them access to what the post was like before, and if they open a new tab, they can copy and paste an old revision’s data into the edit screen and manually create a new revision, which also triggers the workflow and preserves the published post.

In the meantime, I have disabled Policy Editors’ access to the revision screen altogether with the following code:

add_action('admin_init', 'workflow_2_block_editor_revisions');
function workflow_2_block_editor_revisions() {
	global $pagenow;
	if($pagenow == 'revision.php') {
		// Get post type of this revision's parent
		$revision = get_post($_GET['revision']);
		$post_type = get_post_type($revision->post_parent);
		$capability = 'publish_' . $post_type . 's';
		if(!current_user_can($capability)) {
			wp_redirect('/wp-admin/index.php');
		}
	}
}

This redirects the user back to the main admin Dashboard. This user experience seems less confusing to me than redirecting them back to the Edit screen, and it doesn’t rely on JS or CSS, so if a tech-savvy user tried to manually access the revision screen for a particular revision by typing in the URL, they would still get kicked out.

Summary

Once again, I know this is not necessarily the idea way of setting things up, and I hope I’m missing something that will make it possible to greatly simplify things. I’m also aware that this is not completely DRY code, but it is successfully keeping published policies public and triggering all the emails I need it to. So think of it as a rough draft.

Another thing I’m concerned about here is identifying post types. Explicitly setting them doesn’t account for all the CPTs that other plugins (or themes) may add. So, when workflow is addressed in Core, I believe there will need to be an easy way for admins to toggle whether or not a particular workflow is enabled for each post type on the site. Perhaps checkboxes in a settings page. Or, perhaps this could be set up when you register the post type.

Really, it would be helpful not only to toggle workflow on or off, it would be even better to enable a specific workflow for each post type. That way, instead of only relying on what Core can do, we could enable plugins to register a custom workflow – similar to how we can register a custom post type – with certain required formatting and arguments, thus enabling people to set up whatever type of notifications they may want. Or you could make things even more complex and allow each user to select the type of notification they want – a SMS message, an email, no notification. I can’t wait to explore the possibilities once the foundation is in place.

This post is part of a series. Next up: Workflow 2.0: making it easier

Leave a Reply

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