Gutenberg: creating an advanced custom admin user interface for your post types with the block editor using template lock.

As a freelance developer, I want my clients to have an easy to use interface when creating posts. Before WP 5.0 (and before Gutenberg) I used Advanced Custom Fields a lot for this purpose.

An example interface for a Book post, could look something like this:

As you can see, I want to add the most important fields in the main area, and some additional settings and fields could be added in the sidebar.

Now, before Gutenberg this was pretty easy to achieve with Advanced Custom Fields. But since the block editor started to reinvent everything it’s forcing us, developers, to reinvent our custom user interfaces too.

Ideally, we would want to simply replace the Rich text area part with the block-editor. But that’s not possible. If you enable Gutenberg, it will hijack the entire screen, only leaving a custom-fields area at the very bottom for backwards compatibility.

While you could stuff all your custom fields in the bottom area, it’s not a fun user experience for your clients. If they need to scroll all the way down to fill out mandatory fields, that just feels… wrong.

We could also cram everything in the sidebar. But I think there’s simply not enough room there to create a nice flow.

Embrace the block editor

We need to start thinking with blocks, because it’s all we have. Concepts like shortcodes and metadata, that we all got to know and love, are going to disappear.

We have gotten so used to these concepts, that we have probably even forgotten how frustrating they can be to work with. For example, did you ever ask your client to add a snippet of code like [my-gallery ids="12,24,37" columns="3"], and edit it directly?

While this might work for some clients, often we end up editing their website ourselves and charging them extra for it. While we actually promised them a CMS they could use to edit everything themselves. Not cool.

Use a block template

It is possible to define a unique block template for each content type. You can define these templates in your PHP code inside the register_post_type function. For our Book post type, it might look something like this:

$labels = [ ... ];

$args = [
    'labels'        => $labels,
    'public'        => true,
    'show_in_rest'  => true,
    'supports'      => [ 'editor', 'thumbnail' ],
    'taxonomies'    => [ 'category' ],
    'template_lock' => 'all',
    'template'      => [
        [ 'myplugin/book-settings' ],
        [ 'myplugin/content-group', [], [
            [ 'core/paragraph' ]
        ] ]
    ]
];

register_post_type( 'book', $args );

First of all, make sure to set 'public' to true, so the post type shows up in your admin interface. Also set 'show_in_rest' to true to enable Gutenberg for this post type.

The 'template' has two custom blocks as the root elements.

The first one, myplugin/book-settings, will hold the fields for “Book title” and “author”.

The second block, myplugin/content-group, will be a custom block, that will work exactly as a group block. (We cannot use the builtin group block for this because we are going to overwrite the template lock on it.)

'template_lock' will be set to 'all', because we want to prevent the user from moving or adding blocks anywhere else then in our content-group area.

Finally, we also add support for the editor and thumbnail, and we enable categories for this post type.

This is what the interface will look like

Create the settings block

We could make sure the settings block saves it’s values as attributes to the block itself, which is the most straight forward way, and would generate HTML that looks like this:

<!-- wp:myplugin/book-settings {
    "bookTitle":"Slaughterhouse Five",
    "author":"Kurt Vonnegut Jr."
}/-->

Adding attributes to blocks like this is well documented, and if you have some experience with creating blocks, you should have no trouble with this.

However, instead of saving the settings to the block itself, I’m going to save them as post meta. This is also explained well in the WP docs, but it’s a bit less common, so I will quickly walk trough the code.

This is the code to register the settings block.

const { registerBlockType } = wp.blocks;
import { TextControl } from '@wordpress/components';

import './editor.scss';

registerBlockType( 'myplugin/book-settings', {
	title: 'Book Settings',
	category: 'common',
	supports: {
		anchor: false,
		html: false,
		reusable: false,
		inserter: false,
		multiple: false,
		className: false,
		customClassName: false,
	},
	attributes: {
		bookTitle: {
			type: 'string',
			source: 'meta',
			meta: '_myplugin_book_title',
		},
		author: {
			type: 'string',
			source: 'meta',
			meta: '_myplugin_author',
		},
	},
	edit( props ) {
		const { className, attributes, setAttributes } = props;
		const { bookTitle, author } = attributes;

		return (
			<div className={ className }>
				<TextControl label="Book Title" value={ bookTitle || '' } onChange={ ( value ) => {
					setAttributes( { bookTitle: value } );
				} } />
				<TextControl label="Author" value={ author || '' } onChange={ ( value ) => {
					setAttributes( { author: value } );
				} } />
			</div>
		);
	},
	save: () => null,
} );

In order to make sure that our attributes are saved as post-meta, we need to add 2 additional properties to each of the attributes:

  • source: ‘meta’
  • meta: ‘_your_custom_field_name’

We also need to make sure that our meta fields are registered. This can be done with the register_post_meta function in the 'init' hook, right after registering our post type, like so:

$custom_fields = [
    '_myplugin_book_title',
    '_myplugin_author',
    '_myplugin_book_isbn',
];

foreach ($custom_fields as $custom_field) {
    register_post_meta('book', $custom_field, array(
        'show_in_rest' => true,
        'type' => 'string',
        'single' => true,
        'sanitize_callback' => 'sanitize_text_field',
        'auth_callback' => function() {
            return current_user_can( 'edit_posts' );
        }
    ));
}

This concludes our settings block. Before we can test this, we need to also create the myplugin/content-group block, since it’s defined in the Book template. So let’s do that now!

Create the Content-group block

The code to create the content-group block, looks like this:

const { registerBlockType } = wp.blocks;
const { InnerBlocks } = wp.editor;

import './editor.scss';

registerBlockType( 'myplugin/content-group', {
	title: 'Content Group',
	category: 'layout',
	supports: {
		anchor: false,
		html: false,
		reusable: true,
		inserter: true,
		multiple: false,
		className: false,
		customClassName: false,
	},
	edit: ( { className } ) => {
		return (
			<div className={ className }>
				<InnerBlocks templateLock={ false } />
			</div>
		);
	},
	save() {
		return (
			<div>
				<InnerBlocks.Content />
			</div>
		);
	},
} );

In the edit function we will return the InnerBlocks component, which will allow for nesting blocks. We also need to set the templateLock={false} to override template_lock='all' that was set during post type registration, otherwise we would not be able to add new blocks.

Then, in the save implementation, we render InnerBlocks.Content. This will be replaced automatically with the content of the nested blocks.

After making sure that we include the JS files for both the settings block and the content-group block, we can create a new Book.

Create a sidebar panel for additional document settings

We’re almost done. The only thing left to do is to create a ISBN number field in the sidebar. There are a couple of ways to go about this, but I will be working with the SlotFill technique, and more specifically with the PluginDocumentSettingPanel. Instead of creating an additional sidebar for our settings, this allows us to inject a custom panel inside the main document sidebar.

const { registerPlugin } = wp.plugins;
const { PluginDocumentSettingPanel } = wp.editPost;
const { useCallback } = wp.element;
const { useSelect, useDispatch } = wp.data;

registerPlugin( 'additional-settings', {
  icon: 'book',
  render: () => {
    const postType = useSelect( select => select( 'core/editor' ).getCurrentPostType() );
    if ( 'book' !== postType ) {
      return null;
    }

    const isbn = useSelect( select => {
      return select( 'core/editor' ).getEditedPostAttribute( 'meta' )._myplugin_book_isbn;
    }, [] );

    const { editPost } = useDispatch( 'core/editor' );
    const onChangeIsbn = useCallback( ( value ) => {
      editPost( { meta: { _myplugin_book_isbn: value } } );
    }, [ ] );

    return (
      <PluginDocumentSettingPanel
        name="additional-settings"
        title="Additional settings"
      >
        <TextControl
          label="ISBN"
          value={ isbn }
          onChange={ onChangeIsbn }
        />
      </PluginDocumentSettingPanel>
    );
  },
} );

Since these settings are not related to a block, we need to use registerPlugin from the wp.plugins package.

registerPlugin has a render property, which is more or less equivalent to registerBlocktype.edit. The main difference with how we rendered blocks, is that registerPlugin does not have an attributes property. So we need to create our own functions to update the post meta.

Let’s break down all the lines of code in the render function.

const postType = useSelect( select => select( 'core/editor' ).getCurrentPostType() );
if ( 'book' !== postType ) {
  return null;
}

The code above makes sure that our custom panel will only be loaded on Book edit screens.

const isbn = useSelect( select => {
  return select( 'core/editor' ).getEditedPostAttribute( 'meta' )._myplugin_book_isbn;
}, [] );

isbn will hold the value of our _myplugin_book_isbn custom field. The useSelect hook ensures that isbn will always have the most up to date value and that the render function will be called each time this value gets updated.

const { editPost } = useDispatch( 'core/editor' );
const onChangeIsbn = useCallback( ( value ) => {
  editPost( { meta: { _myplugin_book_isbn: value } } );
}, [ ] );

To be honest, I’m not 100% sure about this code. useDispatch is not well documented, and I’m not an expert with Redux, but here’s my 2 cents:
Instead of setting the value directly with

wp.data.dispatch( 'core/editor' ).editPost( { meta: { _myplugin_book_isbn: value } } );

which will also work fine, I wanted to give useDispatch a try. To be honest, I’m not sure about the benefits of this yet, but this code seems to work, so I decided to keep it like this. I’ll probably come back and adjust this explanation once I understand why it’s better or worse.

return (
  <PluginDocumentSettingPanel
    name="additional-settings"
    title="Additional settings"
  >
    <TextControl
      label="ISBN"
      value={ isbn }
      onChange={ onChangeIsbn }
    />
  </PluginDocumentSettingPanel>
);

The code above, renders the actual components.

Check out the full source code on GitHub.

Problems with this approach

There are a couple of problems with this approach.

Most of the problems result from using template_lock=true in combination with templateLock={false}, which is not working as it should in Gutenberg right now. If you are interested, here’s a GitHub issue about it, that also addresses some workarounds.

Thanks for reading this far! If you catch any errors, please let me know. I’m writing this stuff down as am learning it.