Effective pagination in AdonisJS

Reading time ~8 minutes

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 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
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 some small bits …