A common block I find that is needed in almost all of my WordPress projects is one that displays a list of posts with pagination. This is tricky to get considering blocks are content elements and unlike say WordPress’s native search that is a standalone page. Content elements have to work in the context of being a sibling to other blocks. In order to get pagination to work we need some asynchronous solution. I am impartial to VueJS so here is my solution for a simple block that can return results with pagination.
Repo for reference: https://github.com/mplmnt-chicago/wp-custom-posts-block/
Javascript (app.js)
( function() {
new Vue({
el: document.getElementById('app'),
data: {
apiEndPoint: '/wp-json/v1/posts',
results: {
posts: {},
},
pageNumber: 1,
recordsToShow: 10,
loading: true,
},
computed:{
posts(){
return this.results.posts;
}
},
async created(){
await this.getResults();
},
methods:{
gotoPage(page){
this.pageNumber = page;
this.getResults();
},
prevClick(){
if(this.pageNumber > 1){
this.pageNumber--;
this.getResults();
}
},
nextClick(){
if(this.pageNumber < this.results.posts.length){
this.pageNumber++;
this.getResults();
}
},
async getResults(){
this.results.posts = [];
this.loading = true;
const self = this;
const requestData = {
'page': this.pageNumber,
'per_page': this.recordsToShow
}
const requestOptions = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestData)
};
fetch(this.apiEndPoint, requestOptions)
.then((r) => r.json())
.then((res) => this.results = res)
.then(() => {
self.loading = false;
})
},
}
});
})();
Icon (icon.vsg)
<?xml version="1.0" encoding="utf-8"?>
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 300 300" style="enable-background:new 0 0 300 300;" xml:space="preserve">
<g>
<path d="M274.7,152.5H25.3c-2.9,0-5.3-2.4-5.3-5.3v-6.9c0-2.9,2.4-5.3,5.3-5.3h249.4c2.9,0,5.3,2.4,5.3,5.3v6.9
C280,150.2,277.6,152.5,274.7,152.5z"/>
<path d="M166,178.8H24c-2.2,0-4-1.8-4-4v-9.5c0-2.2,1.8-4,4-4h142c2.2,0,4,1.8,4,4v9.5C170,177,168.2,178.8,166,178.8z"/>
</g>
<g>
<path d="M274.7,210.1H25.3c-2.9,0-5.3-2.4-5.3-5.3v-6.9c0-2.9,2.4-5.3,5.3-5.3h249.4c2.9,0,5.3,2.4,5.3,5.3v6.9
C280,207.7,277.6,210.1,274.7,210.1z"/>
<path d="M166,236.3H24c-2.2,0-4-1.8-4-4v-9.5c0-2.2,1.8-4,4-4h142c2.2,0,4,1.8,4,4v9.5C170,234.5,168.2,236.3,166,236.3z"/>
</g>
<g>
<path d="M274.7,95H25.3c-2.9,0-5.3-2.4-5.3-5.3v-6.9c0-2.9,2.4-5.3,5.3-5.3h249.4c2.9,0,5.3,2.4,5.3,5.3v6.9
C280,92.7,277.6,95,274.7,95z"/>
<path d="M166,121.3H24c-2.2,0-4-1.8-4-4v-9.5c0-2.2,1.8-4,4-4h142c2.2,0,4,1.8,4,4v9.5C170,119.5,168.2,121.3,166,121.3z"/>
</g>
<g>
<path d="M274.7,37.5H25.3c-2.9,0-5.3-2.4-5.3-5.3v-6.9c0-2.9,2.4-5.3,5.3-5.3h249.4c2.9,0,5.3,2.4,5.3,5.3v6.9
C280,35.1,277.6,37.5,274.7,37.5z"/>
<path d="M166,63.8H24c-2.2,0-4-1.8-4-4v-9.5c0-2.2,1.8-4,4-4h142c2.2,0,4,1.8,4,4v9.5C170,62,168.2,63.8,166,63.8z"/>
</g>
<circle cx="107.5" cy="269.5" r="11.5"/>
<circle cx="137.5" cy="269.5" r="11.5"/>
<circle cx="167.5" cy="269.5" r="11.5"/>
<circle cx="197.5" cy="269.5" r="11.5"/>
</svg>
ACF & PHP Code (index.php)
<?php
class BlockNewsList {
public $block_name;
public $block_slug;
public $block_description;
public $block_svg;
public function __construct() {
$this->block_name = "Posts List";
$this->block_slug = "posts-list";
$this->block_description = "A custom posts-list block.";
$this->block_svg = file_get_contents(__DIR__ . '/icon.svg');
}
public function initialize() {
add_action('acf/init', array($this, 'acf_block_init'));
add_action('rest_api_init', function () {
register_rest_route( 'v1', '/posts/', array(
'methods' => 'POST',
'callback' => array($this, 'posts_endpoint')
));
});
}
public function acf_block_init() {
if( function_exists('acf_register_block_type') ) {
acf_register_block_type(
array(
'name' => $this->block_slug,
'title' => __($this->block_name),
'description' => __($this->block_description),
'render_callback' => array($this, 'acf_block_render_callback'),
'category' => 'bootstrap',
'icon' => $this->block_svg,
'keywords' => array($this->block_slug),
'mode' => 'edit',
'supports' => array( 'mode' => false ),
'enqueue_assets' => array($this, 'load_assets'),
)
);
}
}
function posts_endpoint( $request_data ) {
$pageNumber = $request_data['page'];
$recordsToShow = $request_data['per_page'];
$query = new WP_Query(array(
'posts_per_page' => $recordsToShow,
'paged' => $pageNumber,
));
$results = array(); // The array that gets returned
$results['total_pages'] = $query->max_num_pages;
$results['total_records'] = $query->found_posts;
$posts = array();
$key = 0;
while ($query->have_posts()) {
$query->the_post();
$posts[$key]->title = get_the_title();
$posts[$key]->content = get_the_excerpt();
$posts[$key]->date = get_the_date();
$posts[$key]->permalink = get_permalink();
$posts[$key]->featured = get_the_post_thumbnail_url($post->ID, 'large');
$key++;
}
$results['posts'] = $posts;
return $results;
}
function load_assets() {
$plugin_url = plugin_dir_url( __FILE__ );
wp_enqueue_style( 'style', $plugin_url.'styles.css');
wp_enqueue_script( 'vuejs', 'https://cdn.jsdelivr.net/npm/[email protected]/dist/vue.js', array(),'1.0.0', true);
if(!is_admin()) {
wp_enqueue_script( 'script', $plugin_url.'app.js', array('vuejs'), '1.0.0', true );
}
}
function acf_block_render_callback( $block, $content = '', $is_preview = false, $post_id = 0 ) {
$id = $this->block_slug.'-'.$block['id'];
$className = $this->block_slug;
if( !empty($block['anchor']) ) {
$id = $block['anchor'];
}
if( !empty($block['className']) ) {
$className .= ' ' . $block['className'];
}
if( !empty($block['align']) ) {
$className .= ' align' . $block['align'];
}
?>
<section id="<?php echo esc_attr($id); ?>" class="blocks-<?php echo $this->block_slug; ?> <?php echo esc_attr($className); ?>">
<div id="app">
<div v-if="loading">
loading...
</div>
<div v-if="posts.length">
<div class="results-list">
<div v-for="(post, index) in posts" :key="index">
<figure class="news-image" v-if="post.featured" :style="{backgroundImage: 'url(' + post.featured + ')'}"></figure>
<aside class="news-content">
<div class="news-title"><a :href="post.permalink">{{post.title}}</a></div>
<div class="news-date">{{post.date}}</div>
<div class="news-excerpt">{{post.content}}</div>
</aside>
</div>
</div>
<div class="custom-pagination">
<div class="total"><b>{{results.total_records}}</b> posts</div>
<ul class="page-numbers">
<li>
<a class="prev page-numbers" href="#" @click.prevent="prevClick()"></a>
</li>
<li v-for="page in results.total_pages">
<span v-if="page == pageNumber" class="page-numbers current">{{page}}</span>
<a v-else class="page-numbers" href="#" @click.prevent="gotoPage(page)">{{page}}</a>
</li>
<li>
<a class="next page-numbers" href="#" @click.prevent="nextClick()"></a>
</li>
</ul>
</div>
</div>
<div v-if="!posts.length && !loading">
No results!
</div>
</div>
</section>
<?php
// This allows the WP Admin to load the vue object
if(is_admin()){ ?>
<script id="script" src="app.js"></script>
<?php } ?>
<?php
}
}
if( ! defined( 'ABSPATH' ) ) exit; // Exit if accessed directly
if( class_exists('ACF') ) { // Load if ACF is enabled
$BlockNewsList = new BlockNewsList();
$BlockNewsList->initialize();
}
CSS (styles.css)
.blocks-posts-list #app .results-list {
list-style: none;
padding: 0;
margin: 0;
}
.blocks-posts-list #app .results-list > div {
display: flex;
border-bottom: solid 1px #ccc;
padding: 10px 0px;
margin-bottom: 20px;
}
.blocks-posts-list #app .results-list > div .news-image {
width: 150px;
height: 150px;
}
.blocks-posts-list #app .results-list > div .news-content {
flex: 1;
}
.blocks-posts-list #app .results-list > div .news-content .news-title {
margin: 0;
}
.blocks-posts-list #app .results-list > div .news-content .news-title a {
text-decoration: none;
font-weight: bold;
}
.blocks-posts-list #app .results-list > div .news-content .news-date {
color: #727272;
font-size: 12px;
}
.blocks-posts-list #app .results-list > div .news-content .news-excerpt {
margin: 0;
font-size: 14px;
}
.blocks-posts-list #app .custom-pagination {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
margin: 40px 0;
}
.blocks-posts-list #app .custom-pagination .total {
width: 150px;
text-align: center;
color: black;
font-size: 14px;
}
.blocks-posts-list #app .custom-pagination .page-numbers {
display: flex;
flex-direction: row;
list-style: none;
padding: 0;
margin: 0;
}
.blocks-posts-list #app .custom-pagination .page-numbers li {
padding: 5px 5px;
}
.blocks-posts-list #app .custom-pagination .page-numbers li .page-numbers {
transition: all 0.4s ease-in-out;
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
line-height: 30px;
text-align: center;
color: black;
text-decoration: none;
border-radius: 50%;
font-size: 12px;
}
.blocks-posts-list #app .custom-pagination .page-numbers li .page-numbers.prev::before {
content: " ";
border-left: 3px solid black;
border-bottom: 3px solid black;
width: 12px;
height: 12px;
transform: rotate(45deg);
cursor: pointer;
display: block;
position: relative;
left: 2px;
}
.blocks-posts-list #app .custom-pagination .page-numbers li .page-numbers.next::before {
content: " ";
border-right: 3px solid black;
border-top: 3px solid black;
width: 12px;
height: 12px;
transform: rotate(45deg);
cursor: pointer;
display: block;
position: relative;
right: 2px;
}
.blocks-posts-list #app .custom-pagination .page-numbers li .page-numbers:hover {
background-color: white;
}
.blocks-posts-list #app .custom-pagination .page-numbers li .page-numbers.current {
background-color: black;
color: white;
}
.blocks-posts-list #app .custom-pagination .page-numbers li a {
display: block;
}