Blueprint: Pagination Component [Proposed Pull Request]

Created on 23 Apr 2017  ยท  13Comments  ยท  Source: palantir/blueprint

I recently implemented a component for Pagination buttons using blueprint. It implements collapsing the buttons, and can also support loading parameter. I wrote it in jsx, but it's easy to port it to typescript. Would a PR be accepted to add such a component in @blueprintjs/core package? Thanks!

snip 2017-04-23 at 15 59 29

core enhancement

Most helpful comment

If pagination is a dying pattern, what is the modern alternative? For example if a filter gives a result of 200 records (database query), I only show like 10 to keep things fast. Are there better ways?

All 13 comments

@prashnts wow that's pretty neat! we'd happily review a PR, but it may take 2-3 weeks to ship because the team has some internal commitments. are you comfortable with the typescript conversion?

This is pretty neat indeed, but what's the case for adding it to core? I prefer being conservative regarding what gets in, and so far can't see this fit in. I also think it's fair to say that pagination is a dying pattern in modern applications, and even internally we've never had a need for it.

We really should set up that community contrib package/infra to welcome these @giladgray

@giladgray yes, I am. Plus the blueprint source here is a great reference. :)

@llorca while I don't agree that pagination is dying (a lot of enterprise apps would want it), I do agree that the case of adding components composed of core components back to core isn't justified. A contrib infra would be more justified.

@prashnts any chance you have this somewhere to take a look at or have published as -contrib to npm?

Are there any updates on this? Or is the component shown by @prashnts available somewhere?

that screenshot is just a ButtonGroup with some disabled ... buttons, no big deal.

If pagination is a dying pattern, what is the modern alternative? For example if a filter gives a result of 200 records (database query), I only show like 10 to keep things fast. Are there better ways?

Would be nice to have it :D Where I can find that @prashnts ?

A very simple pagination component for blueprint using useReducer hook based on this article https://jasonwatmore.com/post/2017/03/14/react-pagination-example-with-logic-like-google

import { useReducer } from 'react'
import { Button, ButtonGroup, Intent } from '@blueprintjs/core'
import PropTypes from 'prop-types'

const getState = ({ currentPage, size, total }) => {
  const totalPages = Math.ceil(total / size)

  // create an array of pages to ng-repeat in the pager control
  let startPage, endPage
  if (totalPages <= 10) {
    // less than 10 total pages so show all
    startPage = 1
    endPage = totalPages
  } else {
    // more than 10 total pages so calculate start and end pages
    if (currentPage <= 6) {
      startPage = 1
      endPage = 10
    } else if (currentPage + 4 >= totalPages) {
      startPage = totalPages - 9
      endPage = totalPages
    } else {
      startPage = currentPage - 5
      endPage = currentPage + 4
    }
  }
  const pages = [...Array(endPage + 1 - startPage).keys()].map(
    i => startPage + i
  )

  // Too large or small currentPage
  let correctCurrentpage = currentPage
  if (currentPage > totalPages) correctCurrentpage = totalPages
  if (currentPage <= 0) correctCurrentpage = 1

  return {
    currentPage: correctCurrentpage,
    size,
    total,
    pages,
    totalPages
  }
}

const reducer = (state, action) => {
  switch (action.type) {
    case 'PAGE_CHANGE':
      return getState({
        currentPage: action.page,
        size: state.size,
        total: state.total
      })

    default:
      throw new Error()
  }
}

const Pagination = ({ initialPage, total, size, onPageChange }) => {
  const [state, dispatch] = useReducer(
    reducer,
    { currentPage: initialPage, total, size },
    getState
  )

  if (state.totalPages === 1) return null

  return (
    <div>
      <h3>{JSON.stringify(state)}</h3>

      <br />

      <ButtonGroup>
        <Button
          disabled={state.currentPage === 1}
          onClick={() => {
            dispatch({ type: 'PAGE_CHANGE', page: 1 })
            onPageChange(1)
          }}
        >
          First
        </Button>
        {state.pages.map(page => (
          <Button
            key={page}
            intent={state.currentPage === page ? Intent.PRIMARY : Intent.NONE}
            disabled={state.currentPage === page}
            onClick={() => {
              dispatch({ type: 'PAGE_CHANGE', page })
              onPageChange(page)
            }}
          >
            {page}
          </Button>
        ))}
        <Button
          disabled={state.currentPage === state.totalPages}
          onClick={() => {
            dispatch({ type: 'PAGE_CHANGE', page: state.totalPages })
            onPageChange(state.totalPages)
          }}
        >
          Last
        </Button>
      </ButtonGroup>
    </div>
  )
}

Pagination.propTypes = {
  initialPage: PropTypes.number.isRequired,
  size: PropTypes.number.isRequired,
  total: PropTypes.number.isRequired,
  onPageChange: PropTypes.func
}

Pagination.defaultProps = {
  initialPage: 1,
  size: 25
}

export default Pagination

A typescript version base on tphan18's script ( with small change )

import React, { useReducer, Reducer } from 'react';
import { Button, ButtonGroup, Intent } from '@blueprintjs/core';

interface Props {
  initialPage?: number;
  total: number;
  size?: number;
  onPageChange: (page: number) => void;
}

interface initialState {
  currentPage: number;
  size: number;
  total: number;
}

interface State extends initialState {
  pages: number[];
  totalPages: number;
}

type Actions = { type: 'PAGE_CHANGE'; page: number };

const getState = ({ currentPage, size, total }: initialState): State => {
  const totalPages = Math.ceil(total / size);

  // create an array of pages to ng-repeat in the pager control
  let startPage = 0,
    endPage = 0;
  if (totalPages <= 10) {
    // less than 10 total pages so show all
    startPage = 1;
    endPage = totalPages;
  } else {
    // more than 10 total pages so calculate start and end pages
    if (currentPage <= 6) {
      startPage = 1;
      endPage = 10;
    } else if (currentPage + 4 >= totalPages) {
      startPage = totalPages - 9;
      endPage = totalPages;
    } else {
      startPage = currentPage - 5;
      endPage = currentPage + 4;
    }
  }

  const pages = Array.from(
    { length: endPage + 1 - startPage },
    (_, i) => startPage + i
  );

  // Too large or small currentPage
  let correctCurrentpage = currentPage;
  if (currentPage > totalPages) correctCurrentpage = totalPages;
  if (currentPage <= 0) correctCurrentpage = 1;

  return {
    currentPage: correctCurrentpage,
    size,
    total,
    pages,
    totalPages
  };
};

const reducer: Reducer<State, Actions> = (state, action) => {
  switch (action.type) {
    case 'PAGE_CHANGE':
      return getState({
        ...state,
        currentPage: action.page
      });

    default:
      throw new Error();
  }
};

export const Pagination = React.memo<Props>(
  ({ initialPage = 1, total, size = 10, onPageChange }) => {
    const [state, dispatch] = useReducer(
      reducer,
      { currentPage: initialPage, total, size, totalPages: 0 },
      getState
    );

    const changePage = (page: number) => {
      dispatch({ type: 'PAGE_CHANGE', page });
      onPageChange(page);
    };

    if (state.totalPages === 1) return null;

    return (
      <div>
        <ButtonGroup>
          <Button
            disabled={state.currentPage === 1}
            onClick={() => changePage(1)}
          >
            First
          </Button>
          <Button
            icon="chevron-left"
            disabled={state.currentPage === 1}
            onClick={() => changePage(Math.max(1, state.currentPage - 1))}
          />
          {state.pages.map(page => (
            <Button
              key={page}
              intent={state.currentPage === page ? Intent.PRIMARY : Intent.NONE}
              onClick={() => changePage(page)}
            >
              {page}
            </Button>
          ))}
          <Button
            icon="chevron-right"
            disabled={state.currentPage === state.totalPages}
            onClick={() =>
              changePage(Math.min(state.currentPage + 1, state.totalPages))
            }
          />
          <Button
            disabled={state.currentPage === state.totalPages}
            onClick={() => changePage(state.totalPages)}
          >
            Last
          </Button>
        </ButtonGroup>
      </div>
    );
  }
);

For what it's worth, here's another iteration on @Pong420 's TypeScript version. I've added some tooltips, clarified what a lot of the "magic numbers" in the original mean, and added the ellipsis behavior in this proposal. This version always shows an odd number of pages, the current page plus a certain number on either side of it, which is more like what the proposed pull request had.

import {
    Button,
    ButtonGroup,
    Intent,
    Position,
    Tooltip,
} from "@blueprintjs/core";
import { IconNames } from "@blueprintjs/icons";
import * as React from "react";
import { Reducer, useReducer } from "react";

interface Props {
    initialPage?: number;
    total: number;
    size?: number;
    onPageChange: (page: number) => void;
}

interface InitialState {
    currentPage: number;
    size: number;
    total: number;
}

interface State extends InitialState {
    pages: number[];
    showEndEllipsis: boolean;
    showStartEllipsis: boolean;
    totalPages: number;
}

interface Actions {
    // tslint:disable-next-line:no-reserved-keywords
    type: "PAGE_CHANGE";
    page: number;
}

const getState = ({ currentPage, size, total }: InitialState): State => {
    const totalPages = Math.ceil(total / size);

    const PAGES_TO_SHOW = 5;
    const PAGES_ON_EITHER_SIDE = 2;

    let showStartEllipsis = false;
    let showEndEllipsis = false;

    // create an array of pages to repeat in the pager control
    let startPage = 0;
    let endPage = 0;
    if (totalPages <= PAGES_TO_SHOW) {
        // less than PAGES_TO_SHOW total pages, so show all
        startPage = 1;
        endPage = totalPages;
    } else {
        if (currentPage <= PAGES_TO_SHOW - PAGES_ON_EITHER_SIDE) {
            // more than PAGINATION_THRESHOLD total pages so calculate start and end pages
            startPage = 1;
            endPage = PAGES_TO_SHOW;
            showEndEllipsis = true;
        } else if (currentPage + PAGES_ON_EITHER_SIDE >= totalPages) {
            // current page approaching the total pages
            startPage = totalPages - (PAGES_TO_SHOW - 1);
            endPage = totalPages;
            showStartEllipsis = true;
        } else {
            // current page is somewhere in the middle
            startPage = currentPage - PAGES_ON_EITHER_SIDE;
            endPage = currentPage + PAGES_ON_EITHER_SIDE;
            showStartEllipsis = true;
            showEndEllipsis = true;
        }
    }

    const pages = Array.from(
        { length: endPage + 1 - startPage },
        (_, i) => startPage + i
    );

    // Too large or small currentPage
    let correctCurrentPage = currentPage;
    if (currentPage > totalPages) {
        correctCurrentPage = totalPages;
    }
    if (currentPage <= 0) {
        correctCurrentPage = 1;
    }

    return {
        currentPage: correctCurrentPage,
        pages,
        showEndEllipsis,
        showStartEllipsis,
        size,
        total,
        totalPages,
    };
};

const reducer: Reducer<State, Actions> = (state, action) => {
    switch (action.type) {
        case "PAGE_CHANGE":
            return getState({
                ...state,
                currentPage: action.page,
            });

        default:
            throw new Error();
    }
};

export const Pagination = React.memo<Props>(
    ({ initialPage = 1, total, size = 10, onPageChange }) => {
        const [state, dispatch] = useReducer(
            reducer,
            { currentPage: initialPage, total, size, totalPages: 0 },
            getState
        );

        const changePage = (page: number) => {
            dispatch({ type: "PAGE_CHANGE", page });
            onPageChange(page);
        };

        if (state.totalPages === 1) {
            return null;
        }

        return (
            <div>
                <ButtonGroup>
                    <Tooltip
                        content="First Page"
                        disabled={state.currentPage === 1}
                        position={Position.TOP}
                    >
                        <Button
                            disabled={state.currentPage === 1}
                            icon={IconNames.DOUBLE_CHEVRON_LEFT}
                            onClick={() => changePage(1)}
                        />
                    </Tooltip>
                    <Tooltip
                        content="Previous Page"
                        disabled={state.currentPage === 1}
                        position={Position.TOP}
                    >
                        <Button
                            icon={IconNames.CHEVRON_LEFT}
                            disabled={state.currentPage === 1}
                            onClick={() =>
                                changePage(Math.max(1, state.currentPage - 1))
                            }
                        />
                    </Tooltip>
                    {state.showStartEllipsis && (
                        <Button disabled={true}>&#8230;</Button>
                    )}
                    {state.pages.map(page => (
                        <Button
                            key={page}
                            intent={
                                state.currentPage === page
                                    ? Intent.PRIMARY
                                    : Intent.NONE
                            }
                            onClick={() => changePage(page)}
                        >
                            {page}
                        </Button>
                    ))}
                    {state.showEndEllipsis && (
                        <Button disabled={true}>&#8230;</Button>
                    )}
                    <Tooltip
                        content="Next Page"
                        disabled={state.currentPage === state.totalPages}
                        position={Position.TOP}
                    >
                        <Button
                            icon={IconNames.CHEVRON_RIGHT}
                            disabled={state.currentPage === state.totalPages}
                            onClick={() =>
                                changePage(
                                    Math.min(
                                        state.currentPage + 1,
                                        state.totalPages
                                    )
                                )
                            }
                        />
                    </Tooltip>
                    <Tooltip
                        content="Last Page"
                        disabled={state.currentPage === state.totalPages}
                        position={Position.TOP}
                    >
                        <Button
                            disabled={state.currentPage === state.totalPages}
                            icon={IconNames.DOUBLE_CHEVRON_RIGHT}
                            onClick={() => changePage(state.totalPages)}
                        />
                    </Tooltip>
                </ButtonGroup>
            </div>
        );
    }
);

Hey y'all! Pretty glad that my this little PR proposal that I forgot about is still open. :)

I guess you can call me the slightly fatigued with constant churn in JS ecosystem since I still try to keep the code as per standard, but don't get to keep on it. Thanks for keeping blueprintjs stable forever!

This is how I did it back in 2017, but still works. I did make some questionable choices in composition, and might not be super readable but you can copy the ascii arts in comments if you want! It still gets used here: https://github.com/CyberCRI/learn-ext/blob/master/src/components/resources/pagination.js because it "works"!

import React from 'react'
import { Button, ButtonGroup } from '@blueprintjs/core'

const CELL_COUNT = 8

function pagingCells (n, pos, maxCells=CELL_COUNT) {
  // Consider an array of pages with length `n`. Let `p` be cursor position.
  //  [1, 2, 3, ..., n-1, n]
  //
  // Requirements:
  // - In all cases we want to keep `1` and `n` visible.
  // - We cant render more than CELL_COUNT items.
  // - If the cells exceed CELL_COUNT, insert `...` wherever appropriate.
  const offset = n - pos
  const pivot = ~~(maxCells / 2)

  let cells = []

  if (n > CELL_COUNT) {
    // Fill in first and last positions
    cells[0] = { nr: 1 }
    cells[1] = { nr: 2 }
    cells[CELL_COUNT - 2] = { nr: n - 1 }
    cells[CELL_COUNT - 1] = { nr: n }

    if (pos <= pivot) {
      // last ellipse is enabled and the rest of the array is filled
      cells[CELL_COUNT - 2].ellipsis = true
      for (let i = 2; i < CELL_COUNT - 2; i++) {
        cells[i] = { nr: i + 1 }
      }
    } else if (offset < pivot) {
      // a ellipsis is enabled and the later part of array is filled
      cells[1].ellipsis = true
      for (let i = 2; i < CELL_COUNT - 2; i++) {
        cells[i] = { nr: n - CELL_COUNT + i + 1 }
      }
    } else {
      // both a and b ellipsis are enabled
      cells[1].ellipsis = true
      cells[CELL_COUNT - 2].ellipsis = true

      // Current selected is put in centre
      cells[pivot] = { nr: pos }
      // Fill next and prev to mid point
      // CELL_COUNT - 5 := n{MID, FIRST, SECOND, LAST, SECONDLAST}
      for (let i = 1; i < CELL_COUNT - 5; i++) {
        cells[pivot + i] = { nr: pos + i }
        cells[pivot - i] = { nr: pos - i }
      }
    }
  } else {
    for (let i = 0; i < n; i++) {
      cells[i] = { nr: i + 1, ellipsis: false }
    }
  }
  return cells
}

export const Pagination = ({count, cursor, onPaginate, maxCells=CELL_COUNT}) => {
  // Renders a Pagination Button Group, inserting ellipsis based on cursor.
  // โ”Œโ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”
  // โ”‚ < โ”‚ 1 โ”‚ 2 โ”‚ 3 โ”‚ > โ”‚
  // โ””โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”˜
  // โ”Œโ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”
  // โ”‚ < โ”‚ 1 โ”‚ 2 โ”‚ 3 โ”‚ 4 โ”‚...โ”‚ 9 โ”‚10 โ”‚ > โ”‚
  // โ””โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”˜
  // โ”Œโ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”
  // โ”‚ < โ”‚ 1 โ”‚...โ”‚ 4 โ”‚ 5 โ”‚ 6 โ”‚...โ”‚10 โ”‚ > โ”‚
  // โ””โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”˜

  const PrevPage = <Button
    icon='arrow-left'
    disabled={cursor <= 1}
    onClick={() => onPaginate(cursor - 1)}
    text='Previous'/>

  const NextPage = <Button
    rightIcon='arrow-right'
    disabled={cursor >= count}
    onClick={() => onPaginate(cursor + 1)}
    text='Next'/>

  return (
    <ButtonGroup className='pagination'>
      {PrevPage}
      {pagingCells(count, cursor, maxCells).map(({ nr, ellipsis }) =>
        <Button
          text={!ellipsis && nr}
          icon={ellipsis && 'more'}
          disabled={ellipsis}
          key={nr}
          active={nr === cursor}
          onClick={() => onPaginate(nr)}/>
      )}
      {NextPage}
    </ButtonGroup>
  )
}

PS: I am still not convinced about typescript so maybe that's why I "forgot" about the PR ;)

I guess I can add, as a very passive user of bp3 for last few years is basically that its a "pretty neat" toolkit. I've ended up using it for several of my school projects; "production" systems; and more academic user. It never gave me the fatigue. So, thanks y'all. Feature Request? Can you fix the iconSvgPath.js taking huge bundle sizes next? :) I can share some "built with blueprintjs by sleep deprived undergrads" kind of links if anyone's interested. Most are also open source. Be warned, though, it's mostly questionable bodge-job.

I believe this version may have some edge cases; The ancient style code along with ancient tests is available in this gist i just created. I made a gist to be less critical and more helpful if you do decide to use this!

Why consider this, over reducers? Because while the samples are clean and great they unnecessarily depend on complex react specific API. What if you want to redo this in Swift or something? I'd just rewrite that function in Swift, and use whatever swift uses to do the UI classes? (well I haven't actually, but if I had to, it's probably because I don't have time to bikeshed).

True, this UI is slightly complicated thing to get right the first time; which is why I bothered writing a test for it. But to give a context, for new users, that gist may look slightly strange from current Blueprintjs patterns. It's the first version of the code when I made this PR proposal, before BP3 was released and you guys broke all my CSS. But I was grateful for some global prefix in scss code, which I set to "pt-" which held the fort together until I had more time to stop overriding your stylesheets! Some API patterns changed, and we got functional components (about which I need to find some good arguments against, still. They look good pattern, and were easy to adapt with). However the "juice" stayed the same because I didn't see any good reasons to do it again.

So, I didn't follow through on this PR, and maybe I'd have impressed you guys with my all CS skillz and ASCII comments. But really, that's what I knew then and i'm glad it still interests some people! And while pagination pattern is still not dead for me (@giladgray, @llorca, thanks for engaging with a noob, but i've been stubborn to keep using coffeescript ;). It's fun to reinvent the wheel, because everyone gets to learn new (https://xkcd.com/1053/) stuff; and the reducer examples actually increase the composition capabilities of "my version", but I think it should be outside the pure component and use some context or whatever we call global variables these days:).

PS: We've always been fighting about what's good pattern and what's not. It's fun! Check out this rant from Knuth https://www.cs.sjsu.edu/~mak/CS185C/KnuthStructuredProgrammingGoTo.pdf

Was this page helpful?
0 / 5 - 0 ratings

Related issues

tomzaku picture tomzaku  ยท  3Comments

westrem picture westrem  ยท  3Comments

scottfr picture scottfr  ยท  3Comments

sighrobot picture sighrobot  ยท  3Comments

adidahiya picture adidahiya  ยท  3Comments