Paginated lists
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:
// Who opened the board, and the page a button leads to, such as `leaderboard/111/2`
export const leaderboardPage = route('leaderboard/{ownerId:snowflake}/{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:
// 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:
@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 asleaderboard/111111111111111111/2, says whose board it is and which page it shows. Any click, however old, has what it needs. - One definition.
@Commandtakes the sameleaderboardPageroute that builds the ids, so the buttons and the handler can't drift apart, andbuildfails to compile without both params. - A number, checked.
{page:int}hands the handler anumber. 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
ownerIdfrom the same params. It throwsGuardDeniedError, 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:
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('111111111111111111') })
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/111111111111111111/-1', disabled: true }),
expect.objectContaining({ custom_id: 'leaderboard/111111111111111111/1', disabled: false }),
])
})
it('turns to the page a button names, updating the message', async () => {
const customId = leaderboardPage.build({ ownerId: '111111111111111111', page: 2 })
const interaction = createMockInteraction(ButtonInteraction, { customId, user: user('111111111111111111') })
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/111111111111111111/1',
user: user('222222222222222222'),
})
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/111111111111111111/2')?.values).toEqual({ ownerId: '111111111111111111', page: 2 })
expect(route('leaderboard/111111111111111111/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
- Buttons, selects and modals: patterns, typed params and routes.
- Guards: what a guard can answer, and where it runs.
- A role picker: a select menu whose choices arrive as params.