Custom Related Posts block for WordPress 5.3

This block is very much based on my original Related Posts block. However, it contains a major bug fix – in the original block, you always had to change the post type even if you then changed it back to the default type, or it wouldn’t save any data – and it’s also been updated for WP 5.3. Some of the JS was moved around in 5.3, so it’s imported from different places now.

The block still looks the same in the editor:

The block says "Choose the posts you want to display," followed by a "Currently selected" section including the default Post post type and "none selected" message; and an "Add to selections" section including a search input and default search results, with a "done" button to close editing mode

It also still looks the same on the front end, and in the editor when you’re not in edit mode:

Screen shot of the list of related posts, including a thumbnail image and title for each

But, this block and this tutorial have been completely rewritten. You can skip down to the end for the final code, or if you’d like to learn more about what changed and why, read on for a detailed step-by-step approach.

Server-side rendering and when to use it

There are multiple ways to handle a block like this. I very much like the original WooCommerce Block it is modeled after, where there is a clear “edit mode” and a clear “preview mode” both in the Block Editor. It makes it easy to select posts, and equally easy to see what they’ll look like on the front end.

With this approach, the “edit mode” naturally uses the REST API to get information like the title about the posts. The “preview mode” could be handled in different ways – it, too, could use the REST API to get its data. However, I’ve chosen to use server-side rendering for the preview mode because I only have to keep track of post IDs in the JavaScript.

If instead this block used the REST API for the preview mode, the block would also have to keep track of post titles and featured images, and whatever other information we wanted to display. I don’t care for that approach because that type of data may change frequently, and because most information you keep track of in a block ends up being saved to the database.

There’s another benefit to using server-side rendering: if you decide later to change the output – for example, say you switch from displaying each post’s featured image with an overlay, to just showing a bulleted list of the post titles – you only have to touch the PHP. If you read the original tutorial, you’ll see I have gone back and edited a lot of the JavaScript, but that’s to fix a bug and to update the code for the current version of Core. If it’s just layout or even data I want to switch, I’m free to edit my PHP callback function and never worry about compiling JavaScript.

Anyway, on to rebuilding this block!

Step 1: Set up Webpack and a block skeleton

Pick one of these two options:

  • Follow the custom webpack tutorial (replacing “myblockname” with “related-posts”, or
  • Download the Step 1 code on GitHub, rename the containing folder “related-posts-block”, install NodeJS, then in a command prompt, go to the folder you downloaded – “/related-posts-block/” – and type npm install. This will download all the Node packages.

(If it makes you feel any better, this step seems short, but it took me the longest to figure out. Just getting everything set up was the hardest learning curve.)

Step 2: Create attributes and basic components

View step 2 code on GitHub

First, in your command prompt – and inside your “/related-posts-block/” folder – type npm run dev. This will tell Webpack to watch your files and compile changes whenever you save anything.

Next, edit the “/src/blocks/related-posts/index.js” file like so. (An explanation of what we’re changing follows the code.)

const { registerBlockType } = wp.blocks;
import Block from './block';

registerBlockType('my/related-posts', {
    title: 'Related Posts',     
    category: 'widgets',
    icon: 'networking',
    attributes: {
        'editMode': {
            type: 'boolean',
            default: true
        },
        'postIds': {
            type: 'array',
            default: []
        },
        'postType': {
            type: 'string',
            default: 'posts'
        },
        'updated': {
            type: 'string',
            default: ''
        }
    },
    edit( props ) {
        return <Block { ...props } />;
    },
    save() {
        // Rendering in PHP
        return null;
    },
} );

This is exactly what we did in Step 2 of the older version, but to make sure you don’t have to jump around between posts, here’s what this step does:

  • Line 1 has been shortened. It still defines where the registerBlockType() function is coming from, in a simpler way.
  • Line 2 defines the second file – one we’re about to make.
  • Lines 8-24 add attributes. WordPress blocks use attributes, not React state, to save data. Usually, these attributes are used in both the edit() function and the save() function. In our case, since we’re letting PHP handle rendering, we won’t use them in the save function.
    • editMode is a boolean – it will be either true or false. This will determine whether we show our search-and-add editing interface, or our preview.
    • postIds will store a simple array of post IDs. From there, we’ll use the REST API and PHP to get the other post data we need.
    • postType is a string that allows this block to work with more than just Posts. By default, the block will show Posts, but this tutorial will also show you how to enable Pages – and from there, you can add your own custom post types if you like.
    • updated is a very odd attribute, used as a bug fix. Because Core’s setAttributes() function doesn’t always fire immediately, we’re using this attribute to affect state in a way that causes WP to become aware that something has changed, and thus forces all of the attributes to update on time.
  • The edit() function now returns a <Block/> component, which we’re about to build.
  • The save() function returns null. Normally, in a block, you save HTML output to the database. However, since we’re linking out to other posts, we wouldn’t want to hard-code their titles or images (or even their links) inside other posts. If someone deleted a post, suddenly wherever it was included as a Related Post, the link would be broken. And if someone updated the featured image or title, that information would not get updated wherever it is a Related Post. By always pulling the latest info from the database, we can ensure that if a post was deleted, nothing will display, and if a post was updated, the latest changes are reflected sitewide.

Since line 2 says to import a component, we have to define that component or we’re going to get Webpack errors. So, let’s create a new file called “/src/blocks/related-posts/block.js”:

const { BlockControls } = wp.blockEditor;
const { Button, Disabled, Placeholder, Toolbar } = wp.components;
const { serverSideRender: ServerSideRender } = wp;
const { Component, Fragment } = wp.element;
import { SearchPostsControl } from './searchposts.js';

export default class Block extends Component {
	renderEditMode() {
		const { props } = this;
		const { setAttributes } = this.props;
		const onDone = () => {
			setAttributes({ editMode: false });
		}
		return(
			<Placeholder
				label='Choose the posts you want to display'
			>
				<SearchPostsControl { ...props } />
				<Button isDefault onClick={ onDone }>
					Done
				</Button>
			</Placeholder>
		);
	}
	render() {
		const { attributes, setAttributes } = this.props;
		const { editMode } = attributes;
		return (
			<Fragment>
				<BlockControls>
					<Toolbar
						controls={ [
							{
								icon: 'edit',
								title: 'Edit',
								onClick: () => setAttributes({ editMode: !editMode }),
								isActive: editMode
							}
						] }
					/>
				</BlockControls>
				{ editMode ? (
					this.renderEditMode()
				) : (
					<Disabled>
						<ServerSideRender block='my/related-posts' attributes={ attributes } />
					</Disabled>
				) }
			</Fragment>
		);
	}
}

(This file has been updated slightly from version 1 – it pulls all the Core items from locations that changed in Core 5.3.)

This component is in a separate file so that we can create it as a class. This allows us to give it a render() function, which is important because this is what makes it easy to toggle between editing and previewing. Here’s a breakdown of what’s happening in this file:

  • Lines 1-4 pull in Core components, such as a <Toolbar> which will users an easy way to toggle between edit mode and preview mode.
  • Line 5 refers to our final JS file, which we’ll create next.
  • Line 7 defines the <Block> component we’re importing into the main “index.js” file. It has two functions:
    • renderEditMode() is called when the editMode attribute is true. When the block is freshly added, or when the user re-enters editing mode, this function displays a gray <Placeholder> component to show it’s in an editable state. Inside the placeholder, we’ll add our <SearchPostsControl> (from the last JS file) and a button to exit edit mode.
    • render() returns a <Fragment>, which is a WordPress component. Normally, React requires you to return a single HTML element, but sometimes you don’t need the extra markup. The Fragment is a single component, meeting React’s requirements, and it doesn’t add any HTML output, which is what we want here.

Our first two JS files are now complete. The real magic happens in the third file, which we’ll build in smaller steps because there’s a lot of functionality here. Create a new file at “/src/blocks/related-posts/searchposts.js”:

const { Component } = wp.element;
 
export class SearchPostsControl extends Component {
    constructor(props) {
        super(props);
    }
    render() {
        return(
            <div className='search-posts-control'>
                <div className='posts-selected'>
                    <h2>Currently selected:</h2>
                </div>
                <div className='posts-search'>
                    <h2>Add to selections:</h2>
                </div>
            </div>
        );
    }
}

This component is set up as a class, too, because we’ll need it to keep track of state later. Right now, it’s just a stub that we’ll build out a little at a time. Let’s style the divs slightly, just in the Editor because this component only appears in edit mode – never the front end. Create a short new file called “/src/blocks/related-posts/editor.scss”:

.search-posts-control { width:100%; text-align:left; }
.posts-selected, .posts-search { min-height:7em; margin-bottom:1em; padding:0 1em 1em; border:1px solid #777; }

Last step before you can see this block in action: update “/plugin.php” to define the server-side rendering. Like the last JS file, we’ll start out simple and build this out more later:

<?php
/*
Plugin Name: Related Posts tutorial block
*/
add_action('init', 'my_register_related_block');
function my_register_related_block() {
    // register our JavaScript
    wp_register_script(
        'related-block',
        plugins_url('/build/index.js', __FILE__),
        array('wp-blocks', 'wp-element', 'wp-editor')
    );
    // register our front-end styles
    wp_register_style(
        'related-block-style',
        plugins_url('/build/style.css', __FILE__),
        array('wp-block-library')
    );
    // register our editor styles
    wp_register_style(
        'related-block-edit-style',
        plugins_url('/build/editor.css', __FILE__),
        array('wp-edit-blocks')
    );
    // register our block
    register_block_type('my/related-posts', array(
        'editor_script' => 'related-block',
        'editor_style' => 'related-block-edit-style',
        'style' => 'related-block-style',
        'render_callback' => 'get_related_posts',
        'attributes' => array(
            'editMode' => array(
                'type' => 'boolean',
                'default' => true
            ),
            'postIds' => array(
                'type' => 'array',
                'default' => []
            ),
            'postType' => array(
                'type' => 'string',
                'default' => 'posts'
            ),
            'updated' => array(
                'type' => 'string',
                'default' => ''
            )
        )
    ));
}
 
function get_related_posts($attributes) {
    return '<div>Front end view will be displayed here.</div>';
}
?>

These changes accomplish two things:

  • We define the attributes in PHP, exactly as they are defined in the JS. Normally, you don’t have to define block attributes in PHP, but you do have to define them in both places when you’re using server-side rendering. That’s because the PHP needs to know exactly what to expect.
  • We now have a “server-side rendering” function. It just displays a div for now – we’ll do the fancy stuff later.

You can now activate the plugin and add the block. Try adding a few posts, then toggling between edit mode (the default) and preview mode (which you can trigger either by using the “done” button or by using the pencil button in the toolbar). If you publish, you’ll see how the same div appears in both the Editor and the front end.

Step 3: Toggle between Posts and Pages

View step 3 code on GitHub

Let’s let the user switch from related Posts to related Pages. You can close “index.js” and “block.js” – they’re complete. We’ll now be working in “searchposts.js” for a few steps and, at the very end, the “plugin.php” file.

“/src/blocks/related-posts/searchposts.js”:

const { SelectControl } = wp.components;
const { Component } = wp.element;
  
export class SearchPostsControl extends Component {
    constructor(props) {
        super(props);
    }
  
    changePostType(newType) {
        // Clear postIds, update postType Attribute
        let { setAttributes } = this.props;
        setAttributes({ postType: newType });
    }
  
    render() {
        let { attributes: { postType } } = this.props;
        return(
            <div className='search-posts-control'>
                <div className='posts-selected'>
                    <h2>Currently selected:</h2>
                    <SelectControl
                        label='Post Type'
                        value={ postType }
                        options={ [
                            { label: 'Post', value: 'posts' },
                            { label: 'Page', value: 'pages' }
                        ] }
                        onChange={ (val) => { this.changePostType(val) } }
                    />
                </div>
                <div className='posts-search'>
                    <h2>Add to selections:</h2>
                </div>
            </div>
        );
    }
}

Skipping down to the render() function first, we now have a dropdown menu that allows users to choose either Post or Page as the post type. You may want to customize this and add in your own custom post types.

The caveat is, you’ll need to figure out whether the REST API has your CPTs at a singular URL (i.e. “book”) or a plural URL (i.e. “books”). It all depends on how you register it, and you can try pulling up the REST API URL in your web browser to check. In our case, some could be seen as either singular or plural – like “faculty” – so there was a clear REST API URL. But others were clearly singular – like “program” – and it took a bit of troubleshooting to figure out things were breaking because I had tried setting the dropdown value to the plural version, “programs”. This doesn’t matter right now, but once we start calling the REST API for data, it will become crucial to have the right URL.

The dropdown menu’s onChange() function calls a new function, changePostType(). That’s because in addition to updating the postType attribute, we’ll also want to clear any selected postIds from the attributes, since those are related to the previously-selected postType.

We now have a dropdown menu that updates one attribute, but that isn’t very exciting. The next step is where we’ll actually start revealing posts so that eventually, the user can select them.

Step 4: Get default posts

View step 4 code on GitHub

This block allows the user to search for specific posts, but to make it even easier on them, we’ll offer a batch of default posts as soon as they add the block. Even if they don’t choose one of the default posts, this will provide a consistent user experience, always showing posts available for selection in the same section.

Let’s tweak our editor CSS so it’s ready for the new elements we’re about to add. “/src/blocks/related-posts/editor.scss”:

.search-posts-control { width:100%; text-align:left; }
.posts-selected, .posts-search { min-height:7em; margin-bottom:1em; padding:0 1em 1em; border:1px solid #777; }
.posts-selected button.is-destructive { position:relative; max-width:calc(100% - .1em); overflow:hidden; }
.posts-selected button.is-destructive:after { display:block; position:absolute; right:0; width:28px; content:'x'; border-radius:100%; color:#fff; background:#d00; }
.posts-list div[role="menu"] { max-height:15em; overflow-y:auto; }
.posts-list button { position:relative; padding:1em 1em 1em 3em; background:#fff; border-bottom:1px solid #aaa; }
.posts-list button:before {display:block; position:absolute; left:.5em; font-family:FontAwesome; font-size:1.5em; content:''; color:#191e23; }
.posts-list button[data-ischecked="true"]:before { content:''; color:#0085ba; }

Note: this CSS requires Font Awesome. You may already have it installed and included in the Editor – check first, as popular plugins like Gravity Forms and Wordfence already put it there. But if it’s not enqueued on the admin side, add these lines in “plugin.php” to make this work (this uses the free version, not the latest one, which is paid):

add_action('admin_enqueue_scripts', 'related_block_enqueue');
function related_block_enqueue() {
    wp_enqueue_style('font-awesome', 'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css');
}

And now, for our JavaScript updates, once again working in our last JS file, “searchposts.js”:

const { MenuGroup, MenuItem, SelectControl } = wp.components;
const { Component } = wp.element;
 
export class SearchPostsControl extends Component {
    constructor(props) {
        super(props);
        this.state = {
            resultObjects: [],
            resultButtons: []
        };
        this.buildResultButtons = this.buildResultButtons.bind(this);
        this.changePostType = this.changePostType.bind(this);
        this.getStartingData = this.getStartingData.bind(this);
        this.updateSelectedIds = this.updateSelectedIds.bind(this);
        this.searchFor = this.searchFor.bind(this);
    }
 
    componentDidMount() {
        this.getStartingData();
    }
 
    buildResultButtons() {
        let { setAttributes } = this.props;
        let resultButtons = this.state.resultObjects.map(function(item, ind) {
            let isChecked = false;
            // Save the opposite value for onClick
            // Must have default true, because if nothing is selected, it's false, and true is what it should change to
            let toCheck = true;
            if(isChecked == true) {
                toCheck = false;
            }
            return(
                <MenuItem id={ item.id } data-ischecked={ isChecked } onClick={ () => this.updateSelectedIds(parseInt(event.target.id), toCheck) }
                >
                    { item.title.rendered }
                </MenuItem>
            );
        }, this);
        // Save timestamp in milliseconds - this forces the setAttributes call for postIds to work
        let timeNow = Date.now();
        this.setState({ resultButtons: resultButtons }, setAttributes({ updated: timeNow }));
    }
 
    changePostType(newType) {
        // Clear postIds, update postType Attribute
        let { setAttributes } = this.props;
        setAttributes({ postType: newType });
        // Clear state and run a new search
        this.setState({ resultObjects: [], resultButtons: []}, this.searchFor(newType));
    }
 
    getStartingData() {
        this.searchFor('');
    }
 
    searchFor(searchPostType = '') {
        let { attributes: { postType } } = this.props;
        let finalPostType = postType;
        // If a post type was explicitly passed to the function, use that instead
        if(searchPostType != '') {
            finalPostType = searchPostType;
        }
        // Make REST API call to get post objects - excluding current ID, but including the postType and keyword if present
        let currentId = wp.data.select('core/editor').getCurrentPostId();
        let path = '/wp/v2/' + finalPostType + '?exclude=' + currentId;
        wp.apiFetch( { path: path } ).then( ( posts ) => {
            this.setState({ resultObjects: posts }, () => this.buildResultButtons());
        })
        .catch( (error) => {
            // Show errors in the console
            console.log('There was an error in the Related Posts block while searching for posts.',error);
        })
    }
 
    updateSelectedIds(id, val) {
        console.log(id + ' checked is ' + val);
    }
 
    render() {
        let { attributes: { postType } } = this.props;
        return(
            <div className='search-posts-control'>
                <div className='posts-selected'>
                    <h2>Currently selected:</h2>
                    <SelectControl
                        label='Post Type'
                        value={ postType }
                        options={ [
                            { label: 'Post', value: 'posts' },
                            { label: 'Page', value: 'pages' },
                        ] }
                        onChange={ (val) => { this.changePostType(val) } }
                    />
                </div>
                <div className='posts-search'>
                    <h2>Add to selections:</h2>
                    <MenuGroup label='Search Results' className='posts-list' >
                        { this.state.resultButtons }
                    </MenuGroup>
                </div>
            </div>
        );
    }
}

Rather than having to access the REST API every time a user enters edit mode, we’re about to use state to store a batch of post objects they can choose from. Lines 7-10 set an initially empty state.

On lines 11-15, we’re binding some new functions to this. Binding them ensures they have access to the other data within the component. By default, JavaScript scopes everything much more tightly than PHP, so if we didn’t bind them here, we wouldn’t have access to things like attributes and state in these new functions.

Our new componentDidMount() function is a lot like jQuery’s $( document ).ready(). It will fire as soon as the component is mounted, or basically, added to the DOM. This is a built-in function, like render(). We’re just using it to fire our own getStartingData() function.

Right now, since the user hasn’t had a chance to select any posts, that getStartingData() function (lines 52-54) just performs an initial search. Once we start letting the user choose posts, we’ll also display the selected posts as buttons, so the user can remove selected posts as needed.

The searchFor() function (lines 56-73) first checks to see whether we’ve explicitly passed it a post type to pull posts from. If so, that would override the default, but if not, we fall back to posts – the default value for the postType attribute. Once the post type is certain, we call the REST API to pull post objects, excluding the current post so we don’t link a post to itself.

Promises, promises

On line 66, you’ll see that after we call the REST API, we have a .then() function. JavaScript doesn’t always fire in sequential order, it does things asynchronously to try to take it easy on the browser. So, it has promises, which mean “Wait until a value is found, and then do something else.” You can’t just add a .then() anywhere; the function has to have been built as a promise. Luckily, wp.apiFetch() was built as a promise, so we can wait until we have those post objects and then save them to state.

Depending on your site’s settings, you might want to tweak the path on line 65 to specify the number of posts you want returned. On our sites it pulls 10, a nice fast batch. 100 is the maximum you can grab in a single REST API call, but in this block you’d be scrolling for a long time if you included 100, so 10 works well for us.

On line 67, you may also notice we call setState() with a comma that calls yet another function. setState() allows callbacks, which perform basically the same function for us as the promise – first we’ll wait for state to be set, and then we’ll call the callback function, buildResultButtons().

Back up on lines 22-42, buildResultButtons() takes those post objects we just got and builds a button for each one. These are the buttons the user can use to actually select the related posts.

Since the post objects are stored in an array, we use an array map – which is basically like a foreach loop for all of the items in an array – to create a button for each post. We want users to be able to both add and remove posts with these buttons, so we check whether the post has been selected already. If it’s selected, clicking on the button will de-select it. And if it’s not selected yet, clicking on the button will select the post.

Lines 40-41 contain a cheater attribute – a timestamp. I could not get the attributes to reliably update every time a user interacted with the buttons, so setting state (to save the buttons) and then calling setAttributes() to set this timestamp forces all of the attributes to update and stay in sync with what the user has actually picked. If anyone knows why this wasn’t updating without a superfluous attribute, I would love to update the block to no longer use this cheat.

Now that the user can select posts, changePostType() (lines 44-50) has been updated. Now, when the user changes the post type, not only does that update the postType attribute, it also clears out the saved post objects – because they’re no longer the right post type. We also call the searchFor() function to pull a default batch of posts from the new post type.

Right now, on lines 75-77, you can see that we’re not actually updating post IDs. We’ve done enough work for this step – if you add the block now and click on some of the posts, you’ll see the post ID and whether or not it’s checked (which should always be true, since none are being saved yet).

The final update is on lines 97-98, where we actually display the post-selection buttons. The <MenuGroup> builds a nice little scrollable menu for all of our post buttons.

Step 5: Let users search for posts

View step 5 code on GitHub

Pulling default posts is a good start, but what if the user is looking for something specific, maybe something old that doesn’t come up in the default batch? Let’s build out the search function so they can use a keyword and find specific posts.

This is a much shorter step! 🙂 We’re working solely in “searchposts.js”:

const { MenuGroup, MenuItem, SelectControl, TextControl } = wp.components;
const { Component } = wp.element;
 
export class SearchPostsControl extends Component {
    constructor(props) {
        super(props);
        this.state = {
            resultObjects: [],
            resultButtons: []
        };
        this.buildResultButtons = this.buildResultButtons.bind(this);
        this.changePostType = this.changePostType.bind(this);
        this.getStartingData = this.getStartingData.bind(this);
        this.updateSelectedIds = this.updateSelectedIds.bind(this);
        this.searchFor = this.searchFor.bind(this);
    }

    componentDidMount() {
        this.getStartingData();
    }
 
    buildResultButtons() {
        let { setAttributes } = this.props;
        let resultButtons = this.state.resultObjects.map(function(item, ind) {
            let isChecked = false;
            // Save the opposite value for onClick
            // Must have default true, because if nothing is selected, it's false, and true is what it should change to
            let toCheck = true;
            if(isChecked == true) {
                toCheck = false;
            }
            return(
                <MenuItem id={ item.id } data-ischecked={ isChecked } onClick={ () => this.updateSelectedIds(parseInt(event.target.id), toCheck) }
                >
                    { item.title.rendered }
                </MenuItem>
            );
        }, this);
        // Save timestamp in milliseconds - this forces the setAttributes call for postIds to work
        let timeNow = Date.now();
        this.setState({ resultButtons: resultButtons }, setAttributes({ updated: timeNow }));
    }
 
    changePostType(newType) {
        // Clear postIds, update postType Attribute
        let { setAttributes } = this.props;
        setAttributes({ postType: newType });
        // Clear state and run a new search
        this.setState({ resultObjects: [], resultButtons: []}, this.searchFor(newType));
    }
 
    getStartingData() {
        this.searchFor('');
    }
 
    searchFor(searchPostType = '', keyword = '') {
        let { attributes: { postType } } = this.props;
        let finalPostType = postType;
        // If a post type was explicitly passed to the function, use that instead
        if(searchPostType != '') {
            finalPostType = searchPostType;
        }
        // Make REST API call to get post objects - excluding current ID, but including the postType and keyword if present
        let currentId = wp.data.select('core/editor').getCurrentPostId();
        let path;
        if(keyword != '') {
            path = '/wp/v2/' + finalPostType + '?search=' + keyword + '&exclude=' + currentId;
 
        } else {
            path = '/wp/v2/' + finalPostType + '?exclude=' + currentId;
        }
        wp.apiFetch( { path: path } ).then( ( posts ) => {
            this.setState({ resultObjects: posts }, () => this.buildResultButtons());
        })
        .catch( (error) => {
            // Show errors in the console
            console.log('There was an error in the Related Posts block while searching for posts.',error);
        })
    }
 
    updateSelectedIds(id, val) {
        console.log(id + ' checked is ' + val);
    }
 
    render() {
        let { attributes: { postType } } = this.props;
		// Posts are plural; all others are singular
		let displayType = postType;
		if(postType != 'posts' && postType != 'faculty') {
			displayType += 's';
		}
		let label = 'Search for ' + displayType + ' to display';
        return(
            <div className='search-posts-control'>
                <div className='posts-selected'>
                    <h2>Currently selected:</h2>
                    <SelectControl
                        label='Post Type'
                        value={ postType }
                        options={ [
                            { label: 'Post', value: 'posts' },
                            { label: 'Page', value: 'pages' },
                        ] }
                        onChange={ (val) => { this.changePostType(val) } }
                    />
                </div>
                <div className='posts-search'>
                    <h2>Add to selections:</h2>
                    <TextControl
                        label={ label }
                        type='search'
                        onChange={ (val) => this.searchFor('', val) }
                    />
                    <MenuGroup label='Search Results' className='posts-list' >
                        { this.state.resultButtons }
                    </MenuGroup>
                </div>
            </div>
        );
    }
}

In the render() function, we have a new search input (the <TextControl>), where users can now type in search terms. Every time the typed-in value changes, we call our searchFor() function, which now accepts an optional keyword argument. We pass an empty postType argument to the searchFor() function because we want it to check the saved attribute and continue using that post type.

Lines 63-69 in the searchFor() function now handle the optional keyword. If one is present, we send that through our REST API call. The rest of the code we built out in step 4 takes care of displaying those fresh posts as buttons the user can click on.

Step 6: Save selected posts

View step 6 code on GitHub

Let’s replace our console log of each post ID, so that when the user clicks on a button, it actually saves (or removes) the post ID from attributes.

Once again, we’re only working in “searchposts.js”:

const { MenuGroup, MenuItem, SelectControl, TextControl } = wp.components;
const { Component } = wp.element;
 
export class SearchPostsControl extends Component {
    constructor(props) {
        super(props);
        this.state = {
            resultObjects: [],
            resultButtons: []
        };
        this.buildResultButtons = this.buildResultButtons.bind(this);
        this.changePostType = this.changePostType.bind(this);
        this.getStartingData = this.getStartingData.bind(this);
        this.updateSelectedIds = this.updateSelectedIds.bind(this);
        this.searchFor = this.searchFor.bind(this);
    }

    componentDidMount() {
        this.getStartingData();
    }
 
    buildResultButtons() {
        let { setAttributes } = this.props;
        let resultButtons = this.state.resultObjects.map(function(item, ind) {
            let isChecked = item.checked;
            // Save the opposite value for onClick
            // Must have default true, because if nothing is selected, it's false, and true is what it should change to
            let toCheck = true;
            if(isChecked == true) {
                toCheck = false;
            }
            return(
                <MenuItem id={ item.id } data-ischecked={ isChecked } onClick={ () => this.updateSelectedIds(parseInt(event.target.id), toCheck) }
                >
                    { item.title.rendered }
                </MenuItem>
            );
        }, this);
        // Save timestamp in milliseconds - this forces the setAttributes call for postIds to work
        let timeNow = Date.now();
        this.setState({ resultButtons: resultButtons }, setAttributes({ updated: timeNow }));
    }
 
    changePostType(newType) {
        // Clear postIds, update postType Attribute
        let { setAttributes } = this.props;
        setAttributes({ postType: newType });
        // Clear state and run a new search
        this.setState({ resultObjects: [], resultButtons: []}, this.searchFor(newType));
    }
 
    getStartingData() {
        this.searchFor('');
    }
 
    searchFor(searchPostType = '', keyword = '') {
        let { attributes: { postType } } = this.props;
        let finalPostType = postType;
        // If a post type was explicitly passed to the function, use that instead
        if(searchPostType != '') {
            finalPostType = searchPostType;
        }
        // Make REST API call to get post objects - excluding current ID, but including the postType and keyword if present
        let currentId = wp.data.select('core/editor').getCurrentPostId();
        let path;
        if(keyword != '') {
            path = '/wp/v2/' + finalPostType + '?search=' + keyword + '&exclude=' + currentId;
 
        } else {
            path = '/wp/v2/' + finalPostType + '?exclude=' + currentId;
        }
        wp.apiFetch( { path: path } ).then( ( posts ) => {
            this.setState({ resultObjects: posts }, () => this.buildResultButtons());
        })
        .catch( (error) => {
            // Show errors in the console
            console.log('There was an error in the Related Posts block while searching for posts.',error);
        })
    }
 
    updateSelectedIds(id, val) {
        let { attributes: { postIds } } = this.props;
        let stateSelected = postIds;
        // Update copy of selectedIds
        if(val == true) {
            stateSelected.push(id);
        } else {
            let idIndex = stateSelected.indexOf(id);
            stateSelected.splice(idIndex, 1);
        }
        // Update copy of resultObjects
        let posts = this.state.resultObjects;
        for(var i = 0; i < posts.length; i++) {
            // if this post ID is in attributes, set checked to true
            posts[i].checked = false;
            for(var j = 0; j < stateSelected.length; j++) {
                if(posts[i].id === stateSelected[j]) {
                    posts[i].checked = true;
                    break;
                }
            }
        }
        // Save resultObjects to state, and then rebuild result buttons
        this.setState({ resultObjects: posts }, function() {
            this.buildResultButtons();
        });
    }
 
    render() {
        let { attributes: { postType } } = this.props;
		// Posts are plural; all others are singular
		let displayType = postType;
		if(postType != 'posts' && postType != 'faculty') {
			displayType += 's';
		}
		let label = 'Search for ' + displayType + ' to display';
        return(
            <div className='search-posts-control'>
                <div className='posts-selected'>
                    <h2>Currently selected:</h2>
                    <SelectControl
                        label='Post Type'
                        value={ postType }
                        options={ [
                            { label: 'Post', value: 'posts' },
                            { label: 'Page', value: 'pages' },
                        ] }
                        onChange={ (val) => { this.changePostType(val) } }
                    />
                </div>
                <div className='posts-search'>
                    <h2>Add to selections:</h2>
                    <TextControl
                        label={ label }
                        type='search'
                        onChange={ (val) => this.searchFor('', val) }
                    />
                    <MenuGroup label='Search Results' className='posts-list' >
                        { this.state.resultButtons }
                    </MenuGroup>
                </div>
            </div>
        );
    }
}

On line 25, instead of assuming every button is not checked, we now check the item’s checked property. Now when the user clicks a button, it actually toggles checked on and off. And thanks to the CSS we added in step 4, when a button’s checked property is true, we’re showing a checkmark icon so it looks like a checked checkbox even though it’s actually a button.

We’ve also replaced our console with actual updating functionality from lines 81-107, in our updateSelectedIds() function. First, we inspect the postIds that are currently in attributes and state. If the function call included a value of “true,” we add the new postId to state. If not, we splice it – meaning we remove it from the array of postIds.

Now, based on the updated array of selected postIds, we loop through the resultObjects (the post objects we’re storing in state) and update whether or not each one is checked. Finally, on line 104, we save the updated resultObjects back to state, followed by a callback – remember, this means the callback doesn’t run until state is fully up to date – to buildResultButtons(). That function rebuilds the selectable post buttons with a data-ischecked attribute for each, so our CSS can then show either an empty checkbox or a checked box.

At this point, users can select and deselect posts – but only while they’re within the current set of search results. Let’s give them a way to see all of the selected postIds at once, to make it easier to tell how many items they’ve chosen so far.

Step 7: Always show the selected posts

View step 7 code on GitHub

We’ve almost completed the JavaScript part of this block. In this step, we’ll finish the JS, and the only thing left will be the PHP in step 8!

“searchposts.js”:

const { Button, MenuGroup, MenuItem, SelectControl, TextControl } = wp.components;
const { Component } = wp.element;
 
export class SearchPostsControl extends Component {
    constructor(props) {
        super(props);
        this.state = {
            selectedButtons: [],
            resultObjects: [],
            resultButtons: []
        };
        this.buildResultButtons = this.buildResultButtons.bind(this);
        this.buildSelectedButtons = this.buildSelectedButtons.bind(this);
        this.changePostType = this.changePostType.bind(this);
        this.getStartingData = this.getStartingData.bind(this);
        this.updateSelectedIds = this.updateSelectedIds.bind(this);
        this.searchFor = this.searchFor.bind(this);
    }

    componentDidMount() {
        this.getStartingData();
    }
 
    buildResultButtons() {
        let { setAttributes } = this.props;
        let resultButtons = this.state.resultObjects.map(function(item, ind) {
            let isChecked = item.checked;
            // Save the opposite value for onClick
            // Must have default true, because if nothing is selected, it's false, and true is what it should change to
            let toCheck = true;
            if(isChecked == true) {
                toCheck = false;
            }
            return(
                <MenuItem id={ item.id } data-ischecked={ isChecked } onClick={ () => this.updateSelectedIds(parseInt(event.target.id), toCheck) }
                >
                    { item.title.rendered }
                </MenuItem>
            );
        }, this);
        // Save timestamp in milliseconds - this forces the setAttributes call for postIds to work
        let timeNow = Date.now();
        this.setState({ resultButtons: resultButtons }, setAttributes({ updated: timeNow }));
    }

    buildSelectedButtons() {
		let { attributes: { postIds, postType }, setAttributes } = this.props;
		// If post IDs are saved in state, get their titles and show buttons
		if(postIds.length > 0) {
			let selectionButtons = [];
			// Get all the post info in a single REST API call
			let path = '/wp/v2/' + postType + '?include=' + postIds;
			wp.apiFetch({ path: path })
				.then( (posts) => {
					selectionButtons = postIds.map((item) => {
						// If this post ID was found in the REST API CALL
						let match;
						for(let i=0; i < posts.length; i++) {
							if(posts[i].id == item) {
								match = i;
								break;
							}
						}
						if(match >= 0) {
							return(
								<Button
									isDefault
									isDestructive
									onClick={ () => this.updateSelectedIds(item, false) }
								>
									{ posts[match].title.rendered }
								</Button>
							);
						} else {
							// If the post ID was not found, remove it from selectedIds
							let idIndex = postIds.indexOf(item);
							postIds.splice(idIndex, 1);
							setAttributes({ postIds, postIds });
							return(
								<p>A previously selected item was removed because it no longer exists.</p>
							);
						}
					})
				})
				.catch( (error) => {
					console.log('Related Posts error',error);
				})
				.then(() =>
					this.setState({ selectedButtons: selectionButtons })
				);
		}
		// If no post IDs, show paragraph
		else {
			this.setState({ selectedButtons: <p>None selected</p> });
		}
	}

    changePostType(newType) {
        // Clear postIds, update postType Attribute
        let { setAttributes } = this.props;
        setAttributes({ postType: newType });
        // Clear state and run a new search
        this.setState({ resultObjects: [], resultButtons: []}, this.searchFor(newType));
    }
 
    getStartingData() {
        this.searchFor('');
    }
 
    searchFor(searchPostType = '', keyword = '') {
        let { attributes: { postType } } = this.props;
        let finalPostType = postType;
        // If a post type was explicitly passed to the function, use that instead
        if(searchPostType != '') {
            finalPostType = searchPostType;
        }
        // Make REST API call to get post objects - excluding current ID, but including the postType and keyword if present
        let currentId = wp.data.select('core/editor').getCurrentPostId();
        let path;
        if(keyword != '') {
            path = '/wp/v2/' + finalPostType + '?search=' + keyword + '&exclude=' + currentId;
 
        } else {
            path = '/wp/v2/' + finalPostType + '?exclude=' + currentId;
        }
        wp.apiFetch( { path: path } ).then( ( posts ) => {
            this.setState({ resultObjects: posts }, () => this.buildResultButtons());
        })
        .catch( (error) => {
            // Show errors in the console
            console.log('There was an error in the Related Posts block while searching for posts.',error);
        })
    }
 
    updateSelectedIds(id, val) {
        let { attributes: { postIds } } = this.props;
        let stateSelected = postIds;
        // Update copy of selectedIds
        if(val == true) {
            stateSelected.push(id);
        } else {
            let idIndex = stateSelected.indexOf(id);
            stateSelected.splice(idIndex, 1);
        }
        // Update copy of resultObjects
        let posts = this.state.resultObjects;
        for(var i = 0; i < posts.length; i++) {
            // if this post ID is in attributes, set checked to true
            posts[i].checked = false;
            for(var j = 0; j < stateSelected.length; j++) {
                if(posts[i].id === stateSelected[j]) {
                    posts[i].checked = true;
                    break;
                }
            }
        }
        // Save resultObjects to state, and then rebuild result buttons
        this.setState({ resultObjects: posts }, function() {
            this.buildSelectedButtons();
            this.buildResultButtons();
        });
    }
 
    render() {
        let { attributes: { postType } } = this.props;
		// Posts are plural; all others are singular
		let displayType = postType;
		if(postType != 'posts' && postType != 'faculty') {
			displayType += 's';
		}
		let label = 'Search for ' + displayType + ' to display';
        return(
            <div className='search-posts-control'>
                <div className='posts-selected'>
                    <h2>Currently selected:</h2>
                    <SelectControl
                        label='Post Type'
                        value={ postType }
                        options={ [
                            { label: 'Post', value: 'posts' },
                            { label: 'Page', value: 'pages' },
                        ] }
                        onChange={ (val) => { this.changePostType(val) } }
                    />
                </div>
                <div className='posts-search'>
                    <h2>Add to selections:</h2>
                    <TextControl
                        label={ label }
                        type='search'
                        onChange={ (val) => this.searchFor('', val) }
                    />
                    <MenuGroup label='Search Results' className='posts-list' >
                        { this.state.resultButtons }
                    </MenuGroup>
                </div>
            </div>
        );
    }
}

Notice on line 1 we’re now getting <Button> from wp.components. We’ll use this for the selected post buttons.

Line 8 defines one last state we’re about to set. (There’s no need to call the REST API constantly to get post titles if we’ve just grabbed that data, so we’re basically temporarily caching it.)

Line 13 binds a new function, buildSelectedButtons(), to this.

Lines 46-96 contain our new function. First, we make sure there are selected postIds. If so, we call the REST API to get all of the post titles at once. (This is a big improvement versus version 1 – it called the REST API once for every post ID, which ran into problems when very many posts were included.)

Then, since we have a whole batch of post titles, we have to loop through them to associate them back to their IDs. Once a match is found, we return a <Button> that shows the post title. When clicked, this button calls updateSelectedIds() to remove that post from the selections.

You may notice, though, there’s an else statement on line 74. What if the related post has been deleted, so it’s not found in the REST API? If that’s the case, we remove the post ID from attributes, and we also display a message warning the user that something has been removed.

We also have a catch statement on line 85. I noticed that on one of our servers, I kept getting errors – the server was returning a 302 “temporarily redirected” status for some of our posts. Unfortunately, because anything other than a 200 response is considered an error, this broke the block. So now instead of breaking everything else, we quietly console out an error message for developers, and the user shouldn’t experience a breaking error.

Notice on line 94, if no posts are currently selected, we save a simple paragraph to state that reinforces to the user that no posts have been selected.

Line 159 has also been added – now when we call updateSelectedIds(), we call both buildSelectedButtons() (the ones that show just the currently-selected posts) and buildResultButtons() (the search results).

Line 185 is also very important and easy to overlook. It displays the selectedButtons state. So, if that state is empty, there are no buttons, but if the state contains buttons, they’ll be displayed. And again, thanks to our earlier styling, these buttons come out with red X buttons to help the user understand that using them will remove the post.

Before you close your command prompt, let’s add the final CSS styles, which you’ll get to see in action in the next step.

” /src/blocks/related-posts/style.scss”:

div.related-block { margin:1em; padding:1em; background:#ddd; box-shadow:0 0 1px #777; }
div.related-posts { display:flex; flex-wrap:wrap; justify-content:space-around; }
div.related-post { flex:1 0 200px; max-width:200px; max-height:200px; margin:.5em; position:relative; }
div.related-post span { position:absolute; bottom:0; left:0; padding:.5em; font-size:.8em; line-height:1em; width:100%; background:rgba(0, 0, 0, .8); color:#fff; }
div.related-post a:hover span, div.related-post a:focus span { background:rgba(0, 50, 10, .8); }

We’re almost done with our custom block! Hang in there with me for one more step: the PHP rendering. You can close your command prompt and go back to your PHP editor of choice for this one.

Step 8: Server-side rendering

View step 8 code on GitHub

The actual PHP rendering isn’t an afterthought, but if you’re like me, it’s comforting to go back to some code that’s a little more familiar at the end. 🙂

Because this block uses the <ServerSideRender> component, it uses a lot more PHP than your average block. Just how much will depend on how many post types you have and how much you want to customize the final HTML output. You can output any post data you want – the JavaScript is just an interface to let the user select IDs, which are then passed over to the PHP – so get as creative as you like.

You’ll probably want to customize this, but to make sure you end this tutorial with a complete, working example, here’s the final file.

“plugin.php”:

<?php
/*
Plugin Name: Related Posts tutorial block
Version: 2.0
*/
add_action('init', 'my_register_related_block');
function my_register_related_block() {
    // register our JavaScript
    wp_register_script(
        'related-block',
        plugins_url('/build/index.js', __FILE__),
        array('wp-blocks', 'wp-element', 'wp-editor')
    );
    // register our front-end styles
    wp_register_style(
        'related-block-style',
        plugins_url('/build/style.css', __FILE__),
        array('wp-block-library')
    );
    // register our editor styles
    wp_register_style(
        'related-block-edit-style',
        plugins_url('/build/editor.css', __FILE__),
        array('wp-edit-blocks')
    );
    // register our block
    register_block_type('my/related-posts', array(
        'editor_script' => 'related-block',
        'editor_style' => 'related-block-edit-style',
        'style' => 'related-block-style',
        'render_callback' => 'get_related_posts',
        'attributes' => array(
            'editMode' => array(
                'type' => 'boolean',
                'default' => true
            ),
            'postIds' => array(
                'type' => 'array',
                'default' => []
            ),
            'postType' => array(
                'type' => 'string',
                'default' => 'posts'
            ),
            'updated' => array(
                'type' => 'string',
                'default' => ''
            )
        )
    ));
}
 
function get_related_posts($attributes) {
    $output = '<div class="related-block"><h2>You might also like...</h2>';
    // Posts
    if($attributes[postType] == 'posts') {
        $output .= '<div class="related-posts">';
        foreach($attributes[postIds] as $postId) {
            $postObject = get_post($postId);
            $output .= '<div class="related-post"><a href="' . get_the_permalink($postObject) . '">';
            if(has_post_thumbnail($postObject)) {
                $thumbId = get_post_thumbnail_id($postObject);
                $altText = get_post_meta($thumbId, '_wp_attachment_image_alt', true);
                $output .= '<img src="' . get_the_post_thumbnail_url($postObject, array(200,200)) . '" alt="' . $altText . '" />';
            } else {
                $output .= '<img src="https://via.placeholder.com/200" alt="No image found" />';
            }
            $output .= '<span>' . $postObject->post_title . '</span></a></div>';
        }
        $output .= '</div>';
    // Pages
    } elseif($attributes[postType] == 'pages') {
        $output .= '<ul>';
        foreach($attributes[postIds] as $postId) {
            $postObject = get_post($postId);
            $output .= '<li><a href="' . get_the_permalink($postObject) . '">' . $postObject->post_title . '</a></li>';
        }
        $output .= '</ul>';
    }
    // Always return output
    return $output;
}
?>

Our changes start at line 53, the beginning of the PHP get_related_posts() function.

For Posts, you’ll see a “You might also like…” heading with a visual display. There’s a placeholder image fallback in case your posts don’t have featured images. But you certainly don’t have to include featured images – it’s really up to you how to display your posts.

For Pages, you’ll see a simpler unordered list of post titles linking to each related posts.

The best part about this block is that if you change your mind later about how you want your HTML to be structured, you can update the PHP to your heart’s content. You won’t run into the dreaded block validation errors that occur when you save specific HTML markup to the database. That’s certainly not saying you should make every block a server-side rendering block – but it sure is an advantage when you have something like this where you’re always going to be querying to get the latest data, not something hard-coded.

Conclusion

I hope this tutorial has cleared up some of the bugs in version 1, and helped you with a sort of starter kit for building your own Related Posts block. I welcome your feedback and comments – there is always so much more to learn!

Leave a Reply

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