I am creating a WordPress plugin and there is a slight learning curve when it comes to using it. I’d like to give users a primer on how to use the plugin, but I want to avoid diverting users to documentation on the plugin’s website since that takes them out of the experience.
What would be great is for users to immediately start using the plugin once it’s installed but have access to helpful tips while they are actively using it. There’s no native feature for something like this in WordPress but we can make something because WordPress is super flexible like that.
So here’s the idea. We’re going to bake documentation directly into the plugin and make it easily accessible in the block editor. This way, users get to use the plugin right away while having answers to common questions directly where they’re working.
My plugin operates through several Custom Post Types (CPT). What we’re going to build is essentially a popup modal that users get when they go to these CPTs.
The WordPress block editor is built in React, which utilizes components that can be customized to and reused for different situations. That is the case with what we’re making — let’s call it the
<Guide> component — which behaves like a modal, but is composed of several pages that the user can paginate through.
WordPress itself has a
<Guide> component that displays a welcome guide when opening the block editor for the first time:
The guide is a container filled with content that’s broken up into individual pages. In other words, it’s pretty much what we want. That means we don’t have to re-invent the wheel with this project; we can reuse this same concept for our own plugin.
Let’s do exactly that.
What we want to achieve
Before we get to the solution, let’s talk about the end goal.
The design satisfies the requirements of the plugin, which is a GraphQL server for WordPress. The plugin offers a variety of CPTs that are edited through custom blocks which, in turn, are defined through templates. There’s a grand total of two blocks: one called “GraphiQL client” to input the GraphQL query, and one called “Persisted query options” to customize the behavior of the execution.
Since creating a query for GraphQL is not a trivial task, I decided to add the guide component to the editor screen for that CPT. It’s available in the Document settings as a panel called “Welcome Guide.”
Crack that panel open and the user gets a link. That link is what will trigger the modal.
For the modal itself, I decided to display a tutorial video on using the CPT on the first page, and then describe in detail all the options available in the CPT on subsequent pages.
I believe this layout is an effective way to show documentation to the user. It is out of the way, but still conveniently close to the action. Sure, we can use a different design or even place the modal trigger somewhere else using a different component instead of repurposing
<Guide>, but this is perfectly good.
Planning the implementation
The implementation comprises the following steps:
- Scaffolding a new script to register the custom sidebar panel
- Displaying the custom sidebar panel on the editor for our Custom Post Type only
- Creating the guide
- Adding content to the guide
Step 1: Scaffolding the script
const registerPlugin = wp.plugins; const PluginDocumentSettingPanel = wp.editPost; const PluginDocumentSettingPanelDemo = () => ( <PluginDocumentSettingPanel name="custom-panel" title="Custom Panel" className="custom-panel" > Custom Panel Contents </PluginDocumentSettingPanel> ); registerPlugin( 'plugin-document-setting-panel-demo', render: PluginDocumentSettingPanelDemo, icon: 'palmtree', );
If you’re experienced with the block editor and already know how to execute this code, then you can skip ahead. I’ve been coding with the block editor for less than three months, and using React/npm/webpack is a new world for me — this plugin is my first project using them! I’ve found that the docs in the Gutenberg repo are not always adequate for beginners like me, and sometimes the documentation is missing altogether, so I’ve had to dig into the source code to find answers.
When the documentation for the component indicates to use that piece of code above, I don’t know what to do next, because
I did, however, find the equivalent ES5 code:
var el = wp.element.createElement; var __ = wp.i18n.__; var registerPlugin = wp.plugins.registerPlugin; var PluginDocumentSettingPanel = wp.editPost.PluginDocumentSettingPanel; function MyDocumentSettingPlugin() return el( PluginDocumentSettingPanel, className: 'my-document-setting-plugin', title: 'My Panel', , __( 'My Document Setting Panel' ) ); registerPlugin( 'my-document-setting-plugin', render: MyDocumentSettingPlugin );
ES5 code does not need be compiled, so we can load it like any other script in WordPress. But I don’t want to use that. I want the full, modern experience of ESNext and JSX.
So my thinking goes like this: I can’t use the block scaffolding tools since it’s not a block, and I don’t know how to compile the script (I’m certainly not going to set-up webpack all by myself). That means I’m stuck.
But wait! The only difference between a block and a regular script is just how they are registered in WordPress. A block is registered like this:
wp_register_script($blockScriptName, $blockScriptURL, $dependencies, $version); register_block_type('my-namespace/my-block', [ 'editor_script' => $blockScriptName, ]);
And a regular script is registered like this:
wp_register_script($scriptName, $scriptURL, $dependencies, $version); wp_enqueue_script($scriptName);
We can use any of the block scaffolding tools to modify things then register a regular script instead of a block, which gains us access to the webpack configuration to compile the ESNext code. Those available tools are:
I chose to use the @wordpress/create-block package because it is maintained by the team developing Gutenberg.
To scaffold the block, we execute this in the command line:
npm init @wordpress/block
After completing all the prompts for information — including the block’s name, title and description — the tool will generate a single-block plugin, with an entry PHP file containing code similar to this:
/** * Registers all block assets so that they can be enqueued through the block editor * in the corresponding context. * * @see https://developer.wordpress.org/block-editor/tutorials/block-tutorial/applying-styles-with-stylesheets/ */ function my_namespace_my_block_block_init() $dir = dirname( __FILE__ ); $script_asset_path = "$dir/build/index.asset.php"; if ( ! file_exists( $script_asset_path ) ) throw new Error( 'You need to run `npm start` or `npm run build` for the "my-namespace/my-block" block first.' ); $index_js = 'build/index.js'; $script_asset = require( $script_asset_path ); wp_register_script( 'my-namespace-my-block-block-editor', plugins_url( $index_js, __FILE__ ), $script_asset['dependencies'], $script_asset['version'] ); $editor_css = 'editor.css'; wp_register_style( 'my-namespace-my-block-block-editor', plugins_url( $editor_css, __FILE__ ), array(), filemtime( "$dir/$editor_css" ) ); $style_css = 'style.css'; wp_register_style( 'my-namespace-my-block-block', plugins_url( $style_css, __FILE__ ), array(), filemtime( "$dir/$style_css" ) ); register_block_type( 'my-namespace/my-block', array( 'editor_script' => 'my-namespace-my-block-block-editor', 'editor_style' => 'my-namespace-my-block-block-editor', 'style' => 'my-namespace-my-block-block', ) ); add_action( 'init', 'my_namespace_my_block_block_init' );
We can copy this code into the plugin, and modify it appropriately, converting the block into a regular script. (Note that I’m also removing the CSS files along the way, but could keep them, if needed.)
function my_script_init() $dir = dirname( __FILE__ ); $script_asset_path = "$dir/build/index.asset.php"; if ( ! file_exists( $script_asset_path ) ) throw new Error( 'You need to run `npm start` or `npm run build` for the "my-script" script first.' ); $index_js = 'build/index.js'; $script_asset = require( $script_asset_path ); wp_register_script( 'my-script', plugins_url( $index_js, __FILE__ ), $script_asset['dependencies'], $script_asset['version'] ); wp_enqueue_script( 'my-script' ); add_action( 'init', 'my_script_init' );
Let’s copy the
package.json file over:
"name": "my-block", "version": "0.1.0", "description": "This is my block", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", "main": "build/index.js", "scripts": "build": "wp-scripts build", "format:js": "wp-scripts format-js", "lint:css": "wp-scripts lint-style", "lint:js": "wp-scripts lint-js", "start": "wp-scripts start", "packages-update": "wp-scripts packages-update" , "devDependencies": "@wordpress/scripts": "^9.1.0"
Now, we can replace the contents of file
src/index.js with the ESNext code from above to register the
<PluginDocumentSettingPanel> component. Upon running
npm start (or
npm run build for production) the code will be compiled into
There is a last problem to solve: the
<PluginDocumentSettingPanel> component is not statically imported, but instead obtained from
wp.editPost, and since
wp is a global variable loaded by WordPress on runtime, this dependency is not present in
index.asset.php (which is auto-generated during build). We must manually add a dependency to the
wp-edit-post script when registering the script to make sure it loads before ours:
$dependencies = array_merge( $script_asset['dependencies'], [ 'wp-edit-post', ] ); wp_register_script( 'my-script', plugins_url( $index_js, __FILE__ ), $dependencies, $script_asset['version'] );
Now the script setup is ready!
The plugin can be updated with Gutenberg’s relentless development cycles. Run
npm run packages-update to update the npm dependencies (and, consequently, the webpack configuration, which is defined on package
"@wordpress/scripts") to their latest supported versions.
At this point, you might be wondering how I knew to add a dependency to the
"wp-edit-post" script before our script. Well, I had to dig into Gutenberg’s source code. The documentation for
<PluginDocumentSettingPanel> is somewhat incomplete, which is a perfect example of how Gutenberg’s documentation is lacking in certain places.
While digging in code and browsing documentation, I discovered a few enlightening things. For example, there are two ways to code our scripts: using either the ES5 or the ESNext syntax. ES5 doesn’t require a build process, and it references instances of code from the runtime environment, most likely through the global
wp variable. For instance, the code to create an icon goes like this:
var moreIcon = wp.element.createElement( 'svg' );
ESNext relies on webpack to resolve all dependencies, which enables us to import static components. For instance, the code to create an icon would be:
import more from '@wordpress/icons';
This applies pretty much everywhere. However, that’s not the case for the
<PluginDocumentSettingPanel> component, which references the runtime environment for ESNext:
const PluginDocumentSettingPanel = wp.editPost;
That’s why we have to add a dependency to the “wp-edit-post” script. That’s where the wp.editPost variable is defined.
<PluginDocumentSettingPanel> could be directly imported, then the dependency to “wp-edit-post” would be automatically handled by the block editor through the Dependency Extraction Webpack Plugin. This plugin builds the bridge from static to runtime by creating a
index.asset.php file containing all the dependencies for the runtime environment scripts, which are obtained by replacing
"@wordpress/" from the package name with
"wp-". Hence, the
"@wordpress/edit-post" package becomes the
"wp-edit-post" runtime script. That’s how I figured out which script to add the dependency.
Step 2: Blacklisting the custom sidebar panel on all other CPTs
The panel will display documentation for a specific CPT, so it must be registered only to that CPT. That means we need to blacklist it from appearing on any other post types.
Ryan Welcher (who created the
<PluginDocumentSettingPanel> component) describes this process when registering the panel:
const registerPlugin = wp.plugins; const PluginDocumentSettingPanel = wp.editPost const withSelect = wp.data; const MyCustomSideBarPanel = ( postType ) => if ( 'post-type-name' !== postType ) return null; return( <PluginDocumentSettingPanel name="my-custom-panel" title="My Custom Panel" > Hello, World! </PluginDocumentSettingPanel> ); const CustomSideBarPanelwithSelect = withSelect( select => return postType: select( 'core/editor' ).getCurrentPostType(), ; )( MyCustomSideBarPanel); registerPlugin( 'my-custom-panel', render: CustomSideBarPanelwithSelect );
He also suggests an alternative solution, using
useSelect instead of
I have created a PHP solution. I’ll admit that it feels a bit hacky, but it works well. First, we find out which post type is related to the object being created or edited:
function get_editing_post_type(): ?string if (!is_admin()) return null; global $pagenow; $typenow = ''; if ( 'post-new.php' === $pagenow ) if ( isset( $_REQUEST['post_type'] ) && post_type_exists( $_REQUEST['post_type'] ) ) $typenow = $_REQUEST['post_type']; ; elseif ( 'post.php' === $pagenow ) if ( isset( $_GET['post'] ) && isset( $_POST['post_ID'] ) && (int) $_GET['post'] !== (int) $_POST['post_ID'] ) // Do nothing elseif ( isset( $_GET['post'] ) ) $post_id = (int) $_GET['post']; elseif ( isset( $_POST['post_ID'] ) ) $post_id = (int) $_POST['post_ID']; if ( $post_id ) $post = get_post( $post_id ); $typenow = $post->post_type; return $typenow;
Then, ,we register the script only if it matches our CPT:
add_action('init', 'maybe_register_script'); function maybe_register_script() // Check if this is the intended custom post type if (get_editing_post_type() != 'my-custom-post-type') return; // Only then register the block wp_register_script(...); wp_enqueue_script(...);
Check out this post for a deeper dive on how this works.
Step 3: Creating the custom guide
I designed the functionality for my plugin’s guide based on the WordPress
<Guide> component. I didn’t realize I’d be doing that at first, so here’s how I was able to figure that out.
- Search the source code to see how it was done there.
- Explore the catalogue of all available components in Gutenberg’s Storybook.
First, I copied content from the block editor modal and did a basic search. The results pointed me to this file. From there I discovered the component is called
<Guide> and could simply copy and paste its code to my plugin as a base for my own guide.
Then I looked for the component’s documentation. I browsed the @wordpress/components package (which, as you may have guessed, is where components are implemented) and found the component’s README file. That gave me all the information I needed to implement my own custom guide component.
I also explored the catalogue of all the available components in Gutenberg’s Storybook (which actually shows that these components can be used outside the context of WordPress). Clicking on all of them, I finally discovered
<Guide>. The storybook provides the source code for several examples (or stories). It’s a handy resource for understanding how to customize a component through props.
At this point, I knew
<Guide> would make a solid base for my component. There is one missing element, though: how to trigger the guide on click. I had to rack my brain for this one!
This is a button with a listener that opens the modal on click:
import useState from '@wordpress/element'; import Button from '@wordpress/components'; import __ from '@wordpress/i18n'; import MyGuide from './guide'; const MyGuideWithButton = ( props ) => const [ isOpen, setOpen ] = useState( false ); return ( <> <Button onClick= () => setOpen( true ) > __('Open Guide: “Creating Persisted Queries”') </Button> isOpen && ( <MyGuide ...props onFinish= () => setOpen( false ) /> ) </> ); ; export default MyGuideWithButton;
Even though the block editor tries to hide it, we are operating within React. Until now, we’ve been dealing with JSX and components. But now we need the
useState hook, which is specific to React.
I’d say that having a good grasp of React is required if you want to master the WordPress block editor. There is no way around it.
Step 4: Adding content to the guide
We’re almost there! Let’s create the
<Guide> component, containing a
<GuidePage> component for each page of content.
The content can use HTML, include other components, and whatnot. In this particular case, I have added three
<GuidePage> instances for my CPT just using HTML. The first page includes a video tutorial and the next two pages contain detailed instructions.
import Guide, GuidePage from '@wordpress/components'; import __ from '@wordpress/i18n'; const MyGuide = ( props ) => return ( <Guide ...props > <GuidePage> <video width="640" height="400" controls> <source src="https://d1c2lqfn9an7pb.cloudfront.net/presentations/graphql-api/videos/graphql-api-creating-persisted-query.mov" type="video/mp4" /> __('Your browser does not support the video tag.') </video> // etc. </GuidePage> <GuidePage> // ... </GuidePage> <GuidePage> // ... </GuidePage> </Guide> ) export default MyGuide;
Not bad! There are a few issues, though:
- I couldn’t embed the video inside the
<Guide>because clicking the play button closes the guide. I assume that’s because the
<iframe>falls outside the boundaries of the guide. I wound up uploading the video file to S3 and serving with
- The page transition in the guide is not very smooth. The block editor’s modal looks alright because all pages have a similar height, but the transition in this one is pretty abrupt.
- The hover effect on buttons could be improved. Hopefully, the Gutenberg team needs to fix this for their own purposes, because my CSS aren’t there. It’s not that my skills are bad; they are nonexistent.
But I can live with these issues. Functionality-wise, I’ve achieved what I need the guide to do.
Bonus: Opening docs independently
<Guide>, we created the content of each
<GuidePage> component directly using HTML. However, if this HTML code is instead added through an autonomous component, then it can be reused for other user interactions.
For instance, the component
<CacheControlDescription> displays a description concerning HTTP caching:
const CacheControlDescription = () => return ( <p>The Cache-Control header will contain the minimum max-age value from all fields/directives involved in the request, or "no-store" if the max-age is 0</p> ) export default CacheControlDescription;
This component can be added inside a
<GuidePage> as we did before, but also within a
import useState from '@wordpress/element'; import Button from '@wordpress/components'; import __ from '@wordpress/i18n'; import CacheControlDescription from './cache-control-desc'; const CacheControlModalWithButton = ( props ) => const [ isOpen, setOpen ] = useState( false ); return ( <> <Button icon="editor-help" onClick= () => setOpen( true ) /> isOpen && ( <Modal ...props onRequestClose= () => setOpen( false ) > <CacheControlDescription /> </Modal> ) </> ); ; export default CacheControlModalWithButton;
To provide a good user experience, we can offer to show the documentation only when the user is interacting with the block. For that, we show or hide the button depending on the value of
import __ from '@wordpress/i18n'; import CacheControlModalWithButton from './modal-with-btn'; const CacheControlHeader = ( props ) => const isSelected = props; return ( <> __('Cache-Control max-age') isSelected && ( <CacheControlModalWithButton /> ) </> ); export default CacheControlHeader;
<CacheControlHeader> component is added to the appropriate control.
The WordPress block editor is quite a piece of software! I was able to accomplish things with it that I would have been unable to without it. Providing documentation to the user may not be the shiniest of examples or use cases, but it’s a very practical one and something that’s relevant for many other plugins. Want to use it for your own plugin? Go for it!