Effective pagination in AdonisJS

 Reading time ~11 minutes

Heads up: this article is over a year old. Some information might be out of date, as I don't always update older articles.

Introduction

Notice: I already did an introduction on AdonisJs, a Laravel-inspired web framework for NodeJS. You can read that post first if you don’t know what AdonisJS is.

In the latest months I tried to get more comfortable with AdonisJS by building a concrete side-project. My impressions so far have been really positive, even though the work required to achieve some basic functionalities is higher and less intuitive compared to the effort required in Laravel. Pagination is a perfect example.

In this post I’m going to present an approach to achieve pagination in AdonisJS in an easy and intuitive way using View Components and View Presenters, with a sprinkle of Tailwind.css for the styling.

Pagination in AdonisJS

AdonisJS provides database pagination out of the box using both the Query builder and the Lucid Orm.

Query Builder

The Query Builder provides two convenient methods to paginate database results:

  • forPage(page, [limit=20])
const users = await Database
  .from('users')
  .forPage(2, 10)

This works like an alias for SQL LIMIT. The query is translated like this

SELECT * FROM users LIMIT 10,10

and returns only the result array, without attached metadata.

  • paginate(page, [limit=20])

To retrieve both results and the metadata you can use the following query instead:

const results = await Database
  .from('users')
  .paginate(2, 10)

The output of the paginate method is then different, because the results are wrapped in the data key

{
  total: '',
  perPage: '',
  lastPage: '',
  page: '',
  data: [{...}]
}
  • total is the total number of results
  • perPage defines how many results there are per page
  • lastPage is the number of the last page
  • page is the number of the current page

Lucid ORM

Lucid also supports the Query Builder paginate method:

const User = use('App/Models/User')
const page = request.get().page || 1

const users = await User.query().paginate(page)

return view.render('users', { users: users.toJSON() })

The output is the same of the Query Builder paginate method.

Display results

We can use the code of the previous post to display the results. We just need to keep in mind that the array of results is in the data key.

@!each(user in users.data, include = 'partials.user')

@if(users.data.length === 0)
    <h4 class="text-black text-center py-3">No users here!</h4>
@endif

As you can see we are using a view partial (parials.user) that contains the logic to display each user.

Warning: Keep in mind the difference between [partials] (https://edge.adonisjs.com/docs/partials) and components: partials share the scope of the parent template, components on the other side work in isolated scope.

Add pagination navigation

Now comes the tricky part. We want to add the HTML navigation for indicating that our content exists between multiple pages. Unlike Laravel, AdonisJS does not provide a function out of the box to render links to the rest of the pages, so we need to implement our own version.

We can start from some very basic HTML code that we can use as a skeleton:

<ul>
    <li>
        <a hrf="#">Previous</a>
    </li>
    <li>
        <a href="#">1</a>
    </li>
    <li>
        <a href="#">2</a>
    </li>
    <li>
        <a href="#">3</a>
    </li>
    <li>
        <a href="#">Next</a>
    </li>
</ul>

By looking at this code we can immediately identify the requirements of pagination:

  • we need to loop through all the pages to build the page numbers
  • the url of “previous” and “next” buttons are dynamic, therefore they need to build the url using the current page as a reference
  • we also need to take into account that there might already be query string parameters into the url and we cannot reset them

As you can see we don’t have instance methods on the paginator like Laravel does, therefore we need to design our logic around the meta fields returned by the query builder. Let’s start with the first implementation:

<ul>
  <li>
    <a href="{{ users.page == 1 ? '#' : '?page=' + (users.page - 1) }}">Previous</a>
  </li>
  @each(page in ???)
    <li>
      <a href="?page={{ page }}">{{ page }}</a>
    </li>
  @endeach
  <li>
    <a href="{{ users.lastPage == users.page ? '#' : '?page=' + (users.page + 1) }}">Next</a>
  </li>
</ul>

We can easily build the url for the “previous” and “next” buttons by checking if we are on the first or on the last page, even though the code is not really bullet-proof.

However as you can see we have a missing spot (???): what can we use as iterator to build the links of the pages? JavaScript does not provide a range function and in any case we cannot really use JavaScript functions inside Edge tags. We need to add a global view helper and code our own version of the range function. Luckily for us ES6 makes this task a breeze. Let’s add this code inside the app/hooks.js file

'use strict'

const { hooks } = require('@adonisjs/ignitor')

hooks.after.providersBooted(() => {
  const View = use('Adonis/Src/View')

  View.global('range', (start, size) => {
    return [...Array(size).keys()].map(i => i + start)
  })
})

Now we can use the function to render the pages links

@each(page in range(1, users.lastPage))
  <li>
    <a href="?page={{ page }}">{{ page }}</a>
  </li>
@endeach

This is a good working implementation, but of course we don’t want to stop here as there is a lot of space for improvements. For example we can extract the code inside a view component so we can use this abstraction every time we need pagination. We just need to move the HTML code inside a dedicated file (for example components/pagination.edge) and replace the references to users with a more generic variable, for example pagination

<ul>
  <li>
    <a href="{{ pagination.page == 1 ? '#' : '?page=' + (pagination.page - 1) }}">Previous</a>
  </li>
  @each(page in range(1, pagination.lastPage))
    <li>
      <a href="?page={{ page }}">{{ page }}</a>
    </li>
  @endeach
  <li>
    <a href="{{ pagination.lastPage == pagination.page ? '#' : '?page=' + (pagination.page + 1) }}">Next</a>
  </li>
</ul>

Now we can use the component every time we need to display pagination

@if(users.data.length)
    <hr>

    @!component('components.pagination', pagination = users)
@endif

As I said before we don’t have instance methods on the paginator, but we can achieve the same functionality using a view presenter. This way we can use JavaScript code to support our pagination component.

Create a new file PaginationPresenter.js in resources/presenters. We can use the following code to abstract functionalities behind expressive functions:

const { BasePresenter } = require('edge.js')

class PaginationPresenter extends BasePresenter {

  isFirst(pagination) {
    return pagination.page == 1
  }

  isCurrent(pagination, page) {
    return pagination.page == page
  }

  isLast(pagination) {
    return pagination.page == pagination.lastPage
  }
}

module.exports = PaginationPresenter

As you can see the presenter is really straightforward. Let’s refactor the HTML to make use of it:

<ul>
  <li>
    <a {{ isFirst(pagination) ? '' : 'href=?page=' + (pagination.page - 1) }}>Previous</a>
  </li>
  @each(page in range(1, pagination.lastPage))
    <li>
      <a {{ isCurrent(pagination, page) ? '' : 'href=?page' + page }}>{{ page }}</a>
    </li>
  @endeach
  <li>
    <a {{ isLast(pagination) ? '' : 'href=?page=' + (pagination.page + 1) }}>Next</a>
  </li>
</ul>

Way better! This expressive syntax simplifies a lot the component. We also use the ternary operator to avoid adding the href attribute to the links, which is valid syntax accordingly to the HTML specification

If the a element has no href attribute, then the element represents a placeholder for where a link might otherwise have been placed, if it had been relevant, consisting of just the element’s contents.

Otherwise, as you might already know, clicking on links using the hash (href="#") has the disadvantage of forcing the browser to move to top.

Now we just need to tell the pagination component to make use of the presenter

@!component('components.pagination', pagination = users, presenter = 'PaginationPresenter')

Finally we can solve the last problem: the page parameter is hardcoded therefore existing query string parameters are removed from the url when the navigating throufgh pages. Laravel provides the appends function, but in AdonisJS we have to code a solution on our own. Luckily for us we can leverage the PaginationPresenter and the URLSearchParams spec for this task.

const url = require('url')

class PaginationPresenter extends BasePresenter {

  [...]

  append(current_url, key, value) {
    const current = url.parse(current_url)
    const params = new URLSearchParams(current.search)

    params.set(key, value)

    return params.toString()
  }
}

module.exports = PaginationPresenter

We use the NodeJS Url API to parse the current request url and the URLSeachParams API to manipulate the query string. Using params.set we can set the value of a parameter without affecting the existing ones. At the end of the function we return the string representation so it can be used inside the component to build the url.

Let’s have a look to the final component:

<ul>
  <li>
    <a {{ isFirst(pagination) ? '' : 'href=?' + append(request.originalUrl(), 'page', pagination.page - 1) }}>Previous</a>
  </li>
  @each(page in range(1, pagination.lastPage))
    <li>
      <a {{ !isCurrent(pagination, page) ? 'href=?' + append(request.originalUrl(), 'page', page) : '' }}>
          {{ page }}
      </a>
    </li>
  @endeach
  <li>
    <a {{ isLast(pagination) ? '' : 'href=?' + append(request.originalUrl(), 'page', pagination.page + 1) }}>Next</a>
  </li>
</ul>

One last thing to note here. As I already told you components work in isolation therefore they don’t have access to the request object, which must be explicitly provided

@!component('components.pagination', pagination = bookmarks, request = request, presenter = 'PaginationPresenter')

Tailwind.css

Finally we can add a bit of styling using Tailwind.css, making it more good-looking and also adding active/disabled styles

<ul class="inline-flex list-reset border border-grey-light rounded w-auto font-sans">
  <li>
    <a
      class="block border-r border-grey-light px-3 py-2 no-underline {{ isFirst(pagination) ? 'text-grey cursor-not-allowed' : 'hover:text-white hover:bg-blue text-blue' }}"
      {{ isFirst(pagination) ? '' : 'href=?' + append(request.originalUrl(), 'page', pagination.page - 1) }}
    >Previous</a>
  </li>
  @each(page in range(1, pagination.lastPage))
    <li>
      <a
        class="block px-3 py-2 no-underline {{ pagination.page == page ? 'text-white bg-blue border-r border-blue' : 'hover:text-white hover:bg-blue text-blue border-r border-grey-light' }}"
        {{ !isCurrent(pagination, page) ? 'href=?' + append(request.originalUrl(), 'page', page) : '' }}
      >
          {{ page }}
      </a>
    </li>
  @endeach
  <li>
    <a
      class="block border-r border-grey-light px-3 py-2 no-underline {{ isLast(pagination) ? 'text-grey cursor-not-allowed' : 'hover:text-white hover:bg-blue text-blue' }}"
      {{ isLast(pagination) ? '' : 'href=?' + append(request.originalUrl(), 'page', pagination.page + 1) }}
    >Next</a>
  </li>
</ul>

The final result is visible in the following picture

Pagination Component

Update (28/02/2020)

Someone pointed out in the comments that a pagination component that displays all the pages is not really usable. That’s a good point, so I decided to update this post to show you how you can easily add a new view component to show pagination links in a smaller fashion, like the following picture.

Smaller Pagination Component

We just need to copy our old code to a new component (for example components/small_pagination.edge) and use a different function to generate the range of pages because now we don’t want to use all the numbers from 1 up to the last page.

Let’s start with this last requirement. We can use our PaginationPresenter.js to define a custom function that generates dynamically an array of the relevant pages given our pagination values, following this custom scheme

      First page  Current page  Last page
           ↓           ↓            ↓
[Previous][1][][5][6][7][8][9][][18][Next]
                ←————→   ←————→
                 delta   delta

The first, current and last pages should always be visible, but we also want to show the delta nearest pages around the current page (2 in this case). All other page numbers should be reduced to the dots () symbol.

This is the implementation:

class PaginationPresenter extends BasePresenter {

  /**
   * Generates a dynamic range of pages.
   *
   * @param  {Object} pagination
   * @param  {Number} delta
   * @return {Array}
   */
  pageDottedRange(pagination, delta = 2) {
    if (pagination.length === 0 || pagination.length === 1) {
      return [];
    }

    var current = parseInt(pagination.page, 10);
    var last = pagination.lastPage;
    var left = current - delta;
    var right = current + delta + 1;
    var range = [];
    var pages = [];
    var l;

    for (var i = 1; i <= last; i++) {
        if (i === 1 || i === last || (i >= left && i < right)) {
            range.push(i);
        }
    }

    range.forEach(function (i) {
      if (l) {
          if (i - l > 1) {
              pages.push('...');
          }
      }
      pages.push(i);
      l = i;
    });

    return pages;

    return pages;
  }
}

The first for cycle just creates the range array of the relevant page numbers. So for example, if our current page is 7 and the last page is 18 the resulting array will be

[ 1, 5, 6, 7, 8, 9, 18 ]

The second forEach cycle finally adds the dots (...) in the page array where the jump between numbers is higher than 1.

[ 1, '...', 5, 6, 7, 8, 9, '...', 18 ]

This page array will be finally used in the view component to render the actual buttons.

<ul class="inline-flex border rounded w-auto font-sans">
  <li>
    <a
      class="block border-r border-gray-400 px-3 py-1 {{ isFirst(pagination) ? 'text-gray-500 cursor-not-allowed' : 'hover:text-white hover:bg-blue-400 text-blue-500' }}"
      {{ isFirst(pagination) ? '' : 'href=?' + appendQueryString(request.originalUrl(), 'page', pagination.page - 1) }}
    >Previous</a>
  </li>
  @each(page in pageDottedRange(pagination, 2))
    @if(page === '...')
    <li>
      <a
        class="block px-3 py-1 hover:text-white hover:bg-blue-400 text-blue-500 border-r border-gray-400"
        href="#"
      >
          ...
      </a>
    </li>
    @else
    <li>
      <a
        class="block px-3 py-1 {{ pagination.page == page ? 'text-white bg-blue-500 border-r border-blue-500' : 'hover:text-white hover:bg-blue-400 text-blue-500 border-r border-gray-400' }}"
        {{ !isCurrent(pagination, page) ? 'href=?' + appendQueryString(request.originalUrl(), 'page', page) : '' }}
      >
          {{ page }}
      </a>
    </li>
    @endif
  @endeach
  <li>
    <a
      class="block border-r border-gray-400 px-3 py-1 {{ isLast(pagination) ? 'text-gray-500 cursor-not-allowed' : 'hover:text-white hover:bg-blue-400 text-blue-500' }}"
      {{ isLast(pagination) ? '' : 'href=?' + appendQueryString(request.originalUrl(), 'page', pagination.page + 1) }}
    >Next</a>
  </li>
</ul>

As you can see the only difference from the previous component is that now we are using the pageRange function provided by the presenter, instead of the global range function. We now just have to check if we have to render a dotted button without link or a regular button.

comments powered by Disqus

How to use presenters in AdonisJs Edge templating engine

Introduction

Being a very passionate Laravel fan, I decided to step up and finally try properly AdonisJs. I already tried …