How to add an asynchronous and paginated custom block in WordPress using VueJS and ACF

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;
}