A Pagination Story

Neverending? Who can tell?

Hey there. Yossef here, and I have a story about pagination for you.

We all know about pagination, right? Instead of showing a huge list of all 30,293 results at once, you get just a slice of 20 of those results (or 50, or 100, or whatever), and you can move through these slices, “page” by “page”.

There’s technical back-end stuff here, but let’s start at the design, what we’re intending to show the user. The standard is seeing (and getting to control) how many entries we’re showing per page, what the current result range is (like “showing 51-100 of 30,293”), and then a set of controls for moving around between pages. Let’s focus on that last bit.

What we got from the design is that we want to show a range of 7 pages, centered around the current page. Like so

1 2 3 4 5 6 7

But there are some caveats. If the last page to be displayed isn’t the absolute last page of results (let’s say there are 10 pages total), indicate that by replacing that page number with an ellipsis

1 2 3 4 5 6 …

Don’t do this with the first page, though. We know where numbers start.

2 3 4 5 6 7 …

And if you’re at the end of the total page range, still show the same number of pages but with the current page not in the middle

1 2 3 4 5 6 …

4 5 6 7 8 9 10

All well and good, right? It’s nice design, and implementing this should be simple enough, right? Right?

About that.

My first hurdle was trying to just add a pagination package to this Elixir / Phoenix project. This quickly ran into problems when phoenix_pagination insisted on a lower version of plug than Phoenix itself did, so I just threw up my hands and decided to do it myself. After all, the project already had everything it needed for paginating queries, including parsing the parameters (entries per page, page number), and I find that “the hard part” of working with pagination and existing packages is configuring which page numbers to show. (This is foreshadowing. Hold on to your hats.)

So I whipped up a PaginationComponent, and put this in its render. (This isn’t the entirety of render, just the part we care about right now.)

<div class="page-number-buttons-wrapper">
  <% {page_nums, ellipsis} = pages_to_show(@total_pages, @page_number, @page_range_window) %>
  <.page_button
    :for={page_num <- page_nums}
    target={@myself}
    page_number={page_num}
    button_text={page_num}
    current={page_num == @page_number}
  />
  <.page_button
    :if={ellipsis}
    target={@myself}
    current={false}
    disabled={true}
    page_number={0}
    button_text="..."
  />
</div>

That’s pretty simple to understand, right? But what’s pages_to_show? I’m glad you asked, but you might not be.

def pages_to_show(total_pages, current_page, page_range_window) do
  half_range_window = (page_range_window / 2) |> trunc()

  # assume the current page will be in the middle and go half down, but not below 1
  first = [current_page - half_range_window, 1] |> Enum.max()
  # now go fully up from there, but not above the last page
  last = [first + page_range_window, total_pages] |> Enum.min()

  # and now readjust the first number if the last had to be capped
  first =
    [first, last - page_range_window]
    |> Enum.min()
    |> then(fn num -> [num, 1] end)
    |> Enum.max()

  # adjust the range and show an ellipsis if it doesn't end at the last page
  ellipsis = last != total_pages
  last = if ellipsis, do: last - 1, else: last

  {first..last, ellipsis}
end

This is… something else. It might be a little harder to read if you’re not familiar with Elixir, but I hope that the variable names and comments help with understanding. Is all that stuff necessary? Actually, yes. Or at least it grew out of fixing problems. I’m not going to say this is the most efficient and correct way to do this, but it does work correctly.

And with that, I dusted off my hands and walked away from this problem, done and satisfied, and never needing to think about this again.

Hey 👋 It’s Amanda and my turn to tell you my part of this saga. Buckle up. 

My part starts with being tasked with seeing whether or not Pagy would work with the client app. We are using Kaminari but the partial file is cumbersome and in need of a serious glow up. Since we were given an updated pagination style, this seemed like the perfect time to experiment with a new gem.

So, I created a pagination view component and got to work. At the start, everything seemed to be going well. Until I got to the nitty gritty of the pagination design — the same design Yossef listed above. The rendering is easy enough, it’s basically one line of code to get the standard pagy navigation:

<%= pagy_nav(@pagy, size:) %>

Unfortunately, Pagy is pretty fixed with how they render the page links. You get the first page, followed by the gap (which is just the ellipsis), then the amount of pages you want to show before current page, next comes the page you are currently on, then the amount of pages you want to show before the right side ellipsis, then the right side ellipsis and finally, the last page. Phew, got all that? If you are a visual learner: 

[1, :gap, 7, 8, "9", 10, 11, :gap, 36]

But I don’t want all of that. Specifically, I don’t want the left ellipsis. Here is where the fun begins. Scouring the docs, the internet, and berating ChatGPT turned up nothing useful besides a GitHub issues thread where someone from Pagy said to modify the series method if you want to mess with the gap(s). Here’s what that series method looks like:

# (from pagy/lib/pagy.rb)

  # Return the array of page numbers and :gap items e.g. [1, :gap, 7, 8, "9", 10, 11, :gap, 36]
  def series(size: @vars[:size], **_)
    return [] if size.empty?
    raise VariableError.new(self, :size, 'to contain 4 items >= 0', size) \
          unless size.is_a?(Array) && size.size == 4 && size.all? { |num| !num.negative? rescue false } # rubocop:disable Style/RescueModifier

    # This algorithm is up to ~5x faster and ~2.3x lighter than the previous one (pagy < 4.3)
    left_gap_start  =  1 + size[0]   # rubocop:disable Layout/ExtraSpacing, Layout/SpaceAroundOperators
    left_gap_end    = @page - size[1] - 1
    right_gap_start = @page + size[2] + 1
    right_gap_end   = @last - size[3]
    left_gap_end    = right_gap_end  if left_gap_end   > right_gap_end
    right_gap_start = left_gap_start if left_gap_start > right_gap_start
    series          = []
    start           = 1
    if (left_gap_end - left_gap_start).positive?
      series.push(*start...left_gap_start, :gap)
      start = left_gap_end + 1
    end
    if (right_gap_end - right_gap_start).positive?
      series.push(*start...right_gap_start, :gap)
      start = right_gap_end + 1
    end
    series.push(*start..@last)
    series[series.index(@page)] = @page.to_s
    series
  end

Woah, that is some code. With some reading, and re-reading (a few times), I was able to realize the left gap came in with this line of code: series.push(*start...left_gap_start, :gap). Adding this entire series method in the pagy initializer file of the app and removing that line of code solved my issue with the left ellipsis. BUT, this felt like a major code smell and I needed some advice and I was desperate for a different solution. Hence why there are three authors to this blog post. Spoiler alert: we did figure out a better solution but it was a bumpy ride.

Hey reader. Welcome to Cody space. Props for making it all the way down here.

Let’s pick back up with our story. We found a line of code doing something that we don’t want. That is, pagy produces “…” on the left of the current page. And like Amanda mentioned, if we want to get rid of the left “…”, well we probably shouldn’t push the left :gap into the series array. So we start there.

  …lots of really cool pagy series stuff
    if (left_gap_end - left_gap_start).positive?
-	 series.push(*start...left_gap_start, :gap)
      start = left_gap_end + 1
    end
  … more really cool pagy series stuff

The function will now return a series that looks like this [7, 8, "9", 10, 11, :gap]. No more left :gap (🎉🥳). We could stop right there. series is “kinda” returning what we want, we have other stuff to do. We are busy people after all. 


BUT frankly it smells 🦨.
Just because Ruby lets us do something, doesn’t mean that we should do that thing. Rewriting series is what is recommended by the gem author, meaning we can be reasonably sure they are aware that folks are out there doing this sort of thing. But there are some of the problems:

  • We have no control over the instance variables that we used. What if the gem author retires, a new maintainer takes over, feels like @page is ambiguous and decides to rename it to be @cur_page. Well 🧨 there goes our series method. 
  • We are no longer using a left :gap. If you go back and take a look at the method we wrote, it sure spends a lot of time talking about a left_gap. Is all that necessary? Probably not.

And I don’t know about you BUT when I look at the function my mind glazes over and I start thinking about an upcoming vacation, or when you got your last tetanus shot, literally anything other than Pagination. 

At this point Yossef, Amanda, and Cody all get into a Zoom room. The quorum has assembled, and we are in agreement, the code smells.

The first change we come up with is the following. 

module PagyCoolness
  included do
    alias_method :series_with_left_gap, :series
    alias_method :series, :series_without_left_gap
  end

  def series_without_left_gap(@vars[:size], **_)
    series = series_with_left_gap(@vars[:size], **_)
    
    if (series[0] == :gap) 
      series[0] = series[1] - 1
    end 
    
    series
  end 
end

class Pagy
  include PagyCoolness
end

It works. And it is much easier to look at. If we take a peek at our above concerns

  • ✅ We are no longer tapping directly into the pagy series method. Mr/Mrs New maintainer wants to rename @page, 🦡 don’t give a 🦆.
  • ✅ We have returned to a state of not caring about the work that pagy does to make sure that the left :gap is positioned correctly within the series array. pagy gonna do what pagy gonna do. We will just modify the returned series array in the way that we want.

But still not good enough, still not quite matching the design

Let’s go back to that weird size variable that’s a four-element array, for reasons

[1, 3, 3, 1] means “1 page before the gap, then gap, then 3 pages before the current page, then 3 pages after, then gap, then 1 page after gap”. That means if you’re near the beginning, this will show as just “1 2 3 4 5 … 10” . We don’t want that. (scroll up if you forgot the design requirements)

So in the end, we just had to rewrite Yossef’s amazing (“amazing”) stuff in Ruby, and it looked like this:

def custom_series(size: @vars[:size], **_)
   half_size = (size / 2)

   # assume the current page will be in the middle and go half down, but not below 1
   left = [@page - half_size, 1].max
   # now go fully up from there, but not above the last page
   right = [left + size, @last].min
   # and now readjust the first number if the last had to be capped
   left = [[left, right - size].min, 1].max

   result = [
     (left...@page).to_a,
     @page.to_s,
     ((@page + 1)..right).to_a
   ].flatten

   if right != @pages
     result[-1] = :gap
   end

   result
 end

Heya. It’s Yossef again!

What have we learned with all this? I could say that maybe developers should be more involved with the design and ensure what comes out is easier to implement, but that wouldn’t be true to myself. I like the design, and I think there can be a healthy tension between design and implementation. Honestly, I wish some pagination packages acted like this “out of the box”.

As far as the implementation, it was far from the end of the world — not even all that difficult, really. There were just a bunch of little edge cases to catch and code for. There’s something to remember here about how “simple” doesn’t necessarily mean “easy”, but there’s also definitely something to be said about defining your pagination with a single number of pages to show, rather than a four-number array.

And finally, as much as I became comfortable or even conversant with Elixir over the course of this project, I think the code is easier to read and understand in the Ruby version. Maybe part of that is the use of :gap and letting Pagy deal with that part, but probably most of it is me having somewhere over a decade of Ruby experience and approximately one year of Elixir experience.

Anyway, let me stress that healthy tension between design and implementation. And definitely take the re-useable packages and tools that are out there, and use them as-is, or tweak them in both small and large ways, but don’t be afraid to get down and dirty and just write the thing yourself if you need to.

If you’re looking for a team to help you discover the right thing to build and help you build it, get in touch.

Published on July 25, 2024