Skip to content
GitHub

Paginated lists

MeoCord 4.1 · since 4.1.0

Show a long list a page at a time, with Previous and Next buttons only the member who asked can press.

A leaderboard too long for one message, shown five entries at a time, with Previous and Next buttons that only the member who asked can press. The buttons carry everything a click needs, so the bot keeps no state between clicks, and they keep working after a restart.

The code

A route holds the buttons' customId: who opened the board, and the page a button leads to. The page is typed int, so it arrives as a number. One function builds a page and its buttons from the route:

recipes/pagination/leaderboard.ts
// Who opened the board, and the page a button leads to, such as `leaderboard/111/2`
export const leaderboardPage = route('leaderboard/{ownerId}/{page:int}')

export const PAGE_SIZE = 5

/** One page of the board, with buttons to its neighbours; a page past either end shows the nearest one. */
export function renderPage(scores: Score[], requested: number, ownerId: string) {
  const last = Math.max(0, Math.ceil(scores.length / PAGE_SIZE) - 1)
  const page = Math.min(Math.max(0, requested), last)
  const rows = scores
    .slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE)
    .map((score, index) => `${page * PAGE_SIZE + index + 1}. ${score.name}: ${score.points}`)

  const button = (label: string, target: number, disabled: boolean) =>
    new ButtonBuilder()
      .setCustomId(leaderboardPage.build({ ownerId, page: target }))
      .setLabel(label)
      .setStyle(ButtonStyle.Secondary)
      .setDisabled(disabled)

  return {
    embeds: [
      new EmbedBuilder()
        .setTitle('Leaderboard')
        .setDescription(rows.join('\n'))
        .setFooter({ text: `Page ${page + 1} of ${last + 1}` }),
    ],
    components: [
      new ActionRowBuilder<ButtonBuilder>().addComponents(
        button('Previous', page - 1, page === 0),
        button('Next', page + 1, page === last),
      ),
    ],
  }
}

A guard lets only the member the button names press it:

recipes/pagination/leaderboard.ts
// Only the member whose id the button carries may press it
@Guard()
export class OwnerGuard implements GuardInterface {
  canActivate(interaction: ButtonInteraction, { ownerId }: { ownerId: string }): boolean {
    // Thrown, it is answered privately; returning false would deny without a word
    if (interaction.user.id !== ownerId) throw new GuardDeniedError('Only the member who opened this can use it.')
    return true
  }
}

The command replies with the first page, and the button handler turns to the page its route names:

recipes/pagination/leaderboard.ts
@Controller()
export class LeaderboardController {
  constructor(private readonly scores: ScoreService) {}

  @Command('leaderboard', LeaderboardCommandBuilder)
  async show(interaction: ChatInputCommandInteraction) {
    await respond(interaction).send(renderPage(this.scores.scores(), 0, interaction.user.id))
  }

  // The page arrives as a number; send() updates the message the button is on
  @Command(leaderboardPage, CommandType.BUTTON)
  @UseGuard(OwnerGuard)
  async turn(interaction: ButtonInteraction, { ownerId, page }: { ownerId: string; page: number }) {
    await respond(interaction).send(renderPage(this.scores.scores(), page, ownerId))
  }
}

How it works

  • No state. Each button's customId, such as leaderboard/111/2, says whose board it is and which page it shows. Any click, however old, has what it needs.
  • One definition. @Command takes the same leaderboardPage route that builds the ids, so the buttons and the handler can't drift apart, and build fails to compile without both params.
  • A number, checked. {page:int} hands the handler a number. An id whose last segment isn't a whole number matches no route.
  • Updating in place. A button's first answer is to its own message, so respond(interaction).send() updates the message the button is on rather than posting a new one. See Responses.
  • Only the owner. The guard reads ownerId from the same params. It throws GuardDeniedError, so anyone else is told why, privately, and the handler never runs. See Guards.
  • Past the end. A page past either end shows the nearest one, so an old message whose list has since shrunk still shows a page.

Testing it

The spec builds a button's id from the route, and checks that an id with a page that isn't a number reaches no handler:

recipes/pagination/leaderboard.spec.ts
describe('LeaderboardController', () => {
  const module = MeoCordTestingModule.create({ controllers: [LeaderboardController] }).compile()

  it('replies with the first page, and buttons that carry who opened it', async () => {
    const interaction = createMockInteraction(ChatInputCommandInteraction, { user: user('111') })

    await module.invoke(LeaderboardController, 'show', interaction)

    const page = sent(interaction)
    expect(getResponse(interaction).calls[0].method).toBe('reply')
    expect(page.embeds[0].footer.text).toBe('Page 1 of 3')
    expect(page.components[0].components).toEqual([
      expect.objectContaining({ custom_id: 'leaderboard/111/-1', disabled: true }),
      expect.objectContaining({ custom_id: 'leaderboard/111/1', disabled: false }),
    ])
  })

  it('turns to the page a button names, updating the message', async () => {
    const customId = leaderboardPage.build({ ownerId: '111', page: 2 })
    const interaction = createMockInteraction(ButtonInteraction, { customId, user: user('111') })

    await module.invoke(LeaderboardController, 'turn', interaction)

    const page = sent(interaction)
    expect(getResponse(interaction).calls[0].method).toBe('update')
    expect(page.embeds[0].description).toBe('11. Kai: 450\n12. Lu: 375')
    expect(page.components[0].components[1].disabled).toBe(true)
  })

  it('refuses anyone else, privately', async () => {
    const interaction = createMockInteraction(ButtonInteraction, { customId: 'leaderboard/111/1', user: user('222') })

    await expect(module.invoke(LeaderboardController, 'turn', interaction)).rejects.toThrow(GuardDeniedError)
    expect(getResponse(interaction).sent).toBe(false)
  })

  it('routes only a page that is a whole number', () => {
    const route = (customId: string) => resolveRoute(App, { type: CommandType.BUTTON, customId })
    expect(route('leaderboard/111/2')?.values).toEqual({ ownerId: '111', page: 2 })
    expect(route('leaderboard/111/last')).toBeUndefined()
  })
})

Variations

Slow pages

If building a page queries a database, add @Defer() to the button handler. It acknowledges within Discord's three seconds and locks the buttons while the page is built.

Jumping to a page

A string select menu with one option per page routes the same way, with the page in the chosen value rather than in its customId. See Select menus.

Next steps