Updating a WordPress plugin with a publish metabox field for the block editor

Many years ago (October 2015, to be exact) I wrote a small plugin that allows you to add a note when updating a post, intended as a way to describe what was changed in that revision. For the developer set, it’s kind of like commit messages for your WordPress post/content updates. It was created to fill a need on various WordPress.org sites, such as the handbooks, but it seemed generally useful so I released it as a public plugin. After one more update in August 2016, it just sat there, usable and useful but without any attention. Until this week.

I stream my open source work semi-regularly, and decided that working through how to adapt an old plugin to the block editor (2 years late) would make for a useful exercise. So I did that, for almost 4 hours! But that’s not how long it takes to do this – the base work of this really took about 45 minutes, with the rest of the time spent chatting and chasing a bug related to revisions. So, for this post I’m going to explain the process of achieving the final code, first demonstrating what most people will need, then what I actually did because of some specific UX needs, and then dissect the revision bug and things I tried that didn’t work out.

So, let’s start by explaining what this plugin was doing in the classic editor:

  • Adds an input in the publish metabox to enter a revision note
  • Saves the revision note both to the post itself and the newly created revision on the save_post hook
  • Displays the revision note on the list of posts and on the revisions screen

To adapt this to the block editor we need to add an input somewhere, for now the parallel location (in the Status & visibility panel) seems to be about as good a choice as any, short of exploring something more involved like a prompt or a popover on the Update button. The rest can hopefully remain the same.

So to start, we have to get ourselves set up to build some JavaScript. This is the part that feels intimidating, and it’s true, writing a build process from scratch is pretty terrifying to me. But, WordPress to the rescue with wp-scripts! So step one is creating a package.json in the root of your directory, like so:

{
    "name": "revision-notes",
    "version": "2.0.0",
    "description": "Add a note explaining the changes you're about to save. It's like commit messages, except for your WordPress content",
    "scripts": {
        "build": "wp-scripts build"
    },
    "author": "Helen Hou-Sandí",
    "license": "GPL-2.0-only",
    "devDependencies": {
        "@wordpress/scripts": "^13.0.3"
    }
}

That’s it! So now, to keep things as low key as possible, I’m just going to use the default file structure wp-scripts build expects, which is everything in src/index.js (whether directly or via imports), which will then build to build/index.js and an accompanying build/index.asset.php. Let’s start by outputting something basic in the spot we want before we get into wiring up the field and the data. To do that, we need to figure out how to put something in the desired spot. In the block editor, we can insert into some predefined core places using SlotFills. Conveniently, the Block Editor Handbook page on SlotFills uses exactly the one we want as the example: PluginPostStatusInfo. So, let’s go ahead and just copy the example code into our JS file:

import { registerPlugin } from '@wordpress/plugins';
import { PluginPostStatusInfo } from '@wordpress/edit-post';

const PluginPostStatusInfoTest = () => (
    <PluginPostStatusInfo>
        <p>Post Status Info SlotFill</p>
    </PluginPostStatusInfo>
);

registerPlugin( 'post-status-info-test', { render: PluginPostStatusInfoTest } )

The part that actually does the SlotFill probably makes you think of HTML, and that’s a great way to think of it. This is JSX, and much like HTML, there are tags that open and close. It also allows for usage of actual HTML tags as can be seen with the paragraph tag, meaning that JSX is a superset of HTML.

Now we need to run npm install && npm run build in the plugin directory on the command line. If all goes well, you should now see the build directory with the two files mentioned inside. Now we need to enqueue this so the block editor can load it in. Since this post is meant for developers who are more familiar with the PHP side of things, this should be more recognizable to you. For the sake of thoroughness, this bit is assuming that you’re okay with anonymous functions (cannot be easily unhooked) and that you’re putting this in a file at the root of your plugin directory. If you’re not, adjust the require path accordingly.

add_action( 'enqueue_block_editor_assets', function() {
    $script_asset = require 'build/index.asset.php';

    wp_enqueue_script(
        'revision-notes-block-editor',
        plugin_dir_url( __FILE__ ) . 'build/index.js',
        $script_asset['dependencies'],
        $script_asset['version']
    );
} );

The thing that probably stands out as a bit different is the way build/index.asset.php is used. It’s a very handy part of using this build process, as it generates a version for you for cache-busting and an array of dependencies so you don’t have to manually keep track of that yourself as you import various things into your JS file. Once this is in place, you can refresh the editor and you should see Post Status Info SlotFill appear in the Status & visibility panel in the sidebar.

Next, let’s add a textarea for the actual note input. This is actually a small departure from the classic editor, where I had used a single line input, but I felt it would be nice to have more space for a note. To do this, we want to use what I consider the real developer powerhouse of the block editor: a component. Namely, the TextareaControl component, which is pretty much exactly what it sounds like. To use it, we need to import it from the appropriate package, which is the one thing I will say is a bit tricky to figure out if you know what you want but aren’t quite sure what it’s named or where to find it. In any case, this one has a comprehensive handbook page, where we can see that it comes from @wordpress/components. So again, before we wire up any data, let’s just get the field outputting. We’ll change our JS file to look like this:

import { TextareaControl } from '@wordpress/components';
import { PluginPostStatusInfo } from '@wordpress/edit-post';
import { __ } from '@wordpress/i18n';
import { registerPlugin } from '@wordpress/plugins';

const RevisionNotesField = () => (
    <PluginPostStatusInfo>
        <TextareaControl
            label={ __( 'Revision note (optional)', 'revision-notes' ) }
            help={ __( 'Enter a brief note about this change', 'revision-notes' ) }
        />
    </PluginPostStatusInfo>
);

registerPlugin(
    'revision-notes',
    {
        render: RevisionNotesField
    }
);

Again, you’ll see that tag-like JSX syntax for the TextareaControl, with some things that look like HTML attributes. These are arguments, or more precisely props, that are being passed to the TextareaControl and will output the label above and the help text below the textarea itself. You can see the full list of accepted props in the handbook, but for the moment we are only concerned with these two. There are also two new import lines at the top, pulling the TextareaControl in from the @wordpress/components package, as well as __ in from @wordpress/i18n for localization of the strings. You will also see that I’ve done some extra whitespacing, renaming to match my plugin, and alphabetized the packages being imported from, which I find helpful in staying organized.

Using __() for localization is pretty much the same as you would do in PHP, with the string itself and the namespace. In this case I’ve copied the same strings used in PHP for the classic editor so that translators don’t have to do extra work. You will notice that the calls are wrapped in { curly braces }, which is essentially like using <?php echo ?> – it evaluates the expression inside a block of JSX rather than considering it to be a string.

So now if you do another npm run build and refresh, you should see a textarea and the accompanying text in the panel. You can also take a peek at build/index.asset.php, where you should see that wp-components and wp-i18n show up in the list of dependencies. Pretty magical!

Now onto the most important part: actually getting and setting the data. First and foremost, you need to expose the data in the REST API so the block editor is aware of it. We can do this using register_meta, which you’ll want to run on the init hook. Note that post here means for any type of post object, not just of the post post type (confusing, I know). This is known as a “primitive”. You can also specify subtypes to be thorough, but for simplicity we’ll just do it this way for now (spoiler: we’re going to get rid of this later because of some special handling due to revisions):

add_action( 'init', function() {
    register_meta( 'post', 'revision_note', array(
        'type' => 'string',
        'single' => true,
        'show_in_rest' => true,
    ) );
} );

Next up, in your JS you need to retrieve the meta and set it. To do this and stay current with the data as the editor updates, you’ll need to use useSelect and useDispatch, which are custom React hooks. Don’t worry about understanding what all that means right now – honestly, I still barely do. Just know that as of this moment using these (and not select and dispatch directly) actually keeps you up to date with editor changes – after all, React is named for the way components “react” to data changes. This means that if you were to expose this same piece of data somewhere else in the editor, like in a block, they will stay in sync with each other. So first, let’s retrieve the meta and use it as the value for the TextareaControl.

import { TextareaControl } from '@wordpress/components';
import { useSelect } from '@wordpress/data';
import { PluginPostStatusInfo } from '@wordpress/edit-post';
import { __ } from '@wordpress/i18n';
import { registerPlugin } from '@wordpress/plugins';

const RevisionNotesField = () => {
    const meta = useSelect((select) =>
        select('core/editor').getEditedPostAttribute('meta'),
    );

    return (
        <PluginPostStatusInfo>
            <TextareaControl
                label={ __( 'Revision note (optional)', 'revision-notes' ) }
                help={ __( 'Enter a brief note about this change', 'revision-notes' ) }
                value={ meta.revision_note }
            />
        </PluginPostStatusInfo>
    );
}

Once again, you’ll see we’ve imported something from a package, this time useSelect from @wordpress/data. We then retrieve the meta attribute from the post object loaded into the editor using useSelect, and then use it as the value for the TextareaControl. One other change we’ve had to make is to the overall JS syntax – previously there was no return, because the RevisionNotesField function only returned JSX so it used a bit of a shortcut. Now that we’re executing another routine before getting into the actual JSX output, we need to wrap it in curly braces and add the return keyword. You can take a moment to run npm run build again and refresh, at which point if there’s a saved revision note it should show up. On to saving the data.

The really nice thing about the block editor and the REST API is that now that you’ve registered the meta, you get saving “for free”. We do need to use useDispatch for this as the construct, but the concept here is that you’ll add an onChange prop to the TextareaControl (remember adding those as HTML attributes?), which will update the meta storage so it’s part of the data that’s sent off the next time you hit the update button.

import { TextareaControl } from '@wordpress/components';
import { useDispatch, useSelect } from '@wordpress/data';
import { PluginPostStatusInfo } from '@wordpress/edit-post';
import { __ } from '@wordpress/i18n';
import { registerPlugin } from '@wordpress/plugins';

const RevisionNotesField = () => {
    const meta = useSelect((select) =>
        select('core/editor').getEditedPostAttribute('meta'),
    );

    return (
        <PluginPostStatusInfo>
            <TextareaControl
                label={ __( 'Revision note (optional)', 'revision-notes' ) }
                help={ __( 'Enter a brief note about this change', 'revision-notes' ) }
                value={ meta.revision_note }
                onChange={
                    (value) => {
                        useDispatch( 'core/editor' ).editPost({
                            meta: { revision_note: value },
                        });
                    }
                }
            />
        </PluginPostStatusInfo>
    );
}

One more add to an import, this time just adding useDispatch to the existing import from @wordpress/data (note that I also alphabetize the items being imported). Then in the onChange prop, we use useDispatch to edit the post object to set the revision_note item in the meta object to be the new value. Now if you do the npm run build routine, refresh the editor, enter a revision note and change something in the post content itself (this is very important because otherwise revisions are not actually saved because there’s nothing different in the revision’s eyes), you should see the revision note show in the posts list table and it will continue to show if you reload the editor for that post. No more PHP to add – all it needed to do there was enqueue the script and register the meta.

Now, for many custom meta integrations, this is it! You’ve set up a build process, registered your meta, and output something into the editor that retrieves, displays, and sets the data, which is then bundled up with the rest of the post data to be saved without further intervention. Congratulations! I hope this makes block integration just a little less intimidating and maybe even exciting thinking about what you can do with all those components hiding inside various @wordpress JS packages.

But wait, there’s more!

This plugin isn’t done, though. We have some issues:

  • Meta is notoriously only saved on the actual post itself, not revisions (i.e. there’s no such thing as previewing meta by default). The existing PHP that saves it on the revision ID does not work with REST requests.
  • The last saved revision note doesn’t actually need to be displayed in the editor, but if we clear it out it will affect the meta object in the editor, leading to a very annoying “unsaved changes” warning even if all you’ve done is load the editor and are trying to back out without having actually changed anything.
  • Related to the previous point, the revision note also needs to clear after you’ve updated a post, because it’s now the last saved revision note, which doesn’t need to display in the editor context.

First, we need to solve for saving the meta on the revision ID. In the classic editor, this was done on the save_post hook, which would run for both the post itself that was being saved and then the revision, in that order per core’s specification, using the note found in the $_POST global. With the REST request the block editor uses for saving, we have two problems: the save_post hook actually runs before meta is set because it’s set separately from the rest of the post data, and reaching into the $_POST data directly is a little bit of a strange pattern.

For the first point, there’s actually a core problem, which is that revision saving is running too early. So the first thing we need to do is work around this by unhooking wp_save_post_revision() from its current place, and running it on a later hook, but only if it’s an update, as revisions don’t need to be saved on initial publish.

if ( has_action( 'post_updated', 'wp_save_post_revision' ) ) {
    remove_action( 'post_updated', 'wp_save_post_revision', 10, 1 );
    add_action( 'wp_after_insert_post', function( $post_id, $post, $update ) {
        if ( ! $update ) {
            return;
        }

        wp_save_post_revision( $post_id );
    }, 10, 3 );
}

Now that we’ve done this, the save_post hook will run for the revision after the parent post’s meta has been saved, meaning we can then copy the revision_note meta from the parent post to the revision if it exists. The following code is simplified, as it leaves out the existing part where we handle the classic editor data for brevity.

add_action( 'save_post', function( $post_id, $post ) {
    if ( 'revision' === get_post_type( $post ) ) {
        $parent = wp_is_post_revision( $post );
        $note = get_post_meta( $parent, 'revision_note', true );

        if ( ! empty( $note ) ) {
            update_metadata( 'post', $post->ID, 'revision_note', wp_slash( $note ) );
        }
    }
}, 10, 2 );

After checking to make sure this is working as expected, let’s turn our attention back over to the JS side of things to solve the remaining two issues. The first thing I tried was using state to track the actual input value and syncing it to the meta underneath, clearing it when the editor changed from saving to not saving using useEffect, but this had two problems of its own: an empty input would not actually mean empty meta underneath because it was syncing in the onChange event, and most annoyingly from a user perspective, you could not use undo/redo. The solutions all looked like they were just going to continue to add more code (and technical debt) to what should be a small plugin, so I rubberducked things out with a coworker and realized there was a better solution if we stopped thinking about the JS and went back to our PHP fundamentals.

Remember using register_meta() to expose the data to the editor? Well, if you think about it, you don’t actually need to expose the revision note to the editor, because it never needs to view or edit an existing one, only set a new one. So the more correct thing to do is to use register_rest_field(), setting the meta in its update_callback and, most importantly to the UX side of things, using __return_empty_string as the get_callback. This means that as far the editor data is concerned, on first load and after saving, the value is always empty, which is exactly what we want. This meant that we didn’t need to track the editor saving state using useEffect, and could now simplify the data getting and setting in a way that didn’t interfere with undo/redo.

First, on the PHP side, we get rid of the register_meta() call, and instead on each supported post type (an existing loop from the initial classic editor integration), call register_rest_field().

add_action( 'init', function() {
    $post_types = get_post_types( array( 'show_ui' => true ) );

    if ( ! empty( $post_types ) ) {
        foreach ( $post_types as $post_type ) {
            register_rest_field(
                $post_type,
                'revision_note',
                array(
                    'get_callback'    => '__return_empty_string',
                    'update_callback' => function ( $value, $post ) {
                        update_post_meta( $post->ID, 'revision_note', $value );
                    },
                    'schema'          => array(
                        'type' => 'string',
                    ),
                )
            );
        }
    }
} );

And then, in the JS, we switch from checking and setting on the meta object to our newly registered revision_note field.

import { TextareaControl } from '@wordpress/components';
import { useDispatch, useSelect } from '@wordpress/data';
import { PluginPostStatusInfo } from '@wordpress/edit-post';
import { __ } from '@wordpress/i18n';
import { registerPlugin } from '@wordpress/plugins';

const RevisionNotesField = () => {
    const revisionNote = useSelect((select) =>
        select('core/editor').getEditedPostAttribute('revision_note'),
    );

    return (
        <PluginPostStatusInfo>
            <TextareaControl
                label={ __( 'Revision note (optional)', 'revision-notes' ) }
                help={ __( 'Enter a brief note about this change', 'revision-notes' ) }
                value={ revisionNote }
                onChange={
                    (value) => {
                        useDispatch( 'core/editor' ).editPost({
                            revision_note: value,
                        });
                    }
                }
            />
        </PluginPostStatusInfo>
    );
}

registerPlugin(
    'revision-notes',
    {
        render: RevisionNotesField
    }
);

Now, finally, my little plugin is working correctly in both the classic editor and the block editor. By keeping my eye on both the PHP and JS side of things, I was able to come up with a very clean solution that should stand the test of time the same way the classic editor integration has. I hope this write up is helpful in your journeys with WordPress editor integration, and let me know if you see things I could do better or that might be cool for Revision Notes to do in the future!

Leave a Reply

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