Pagination
Page through a long list with Paginator, so a user walks it with buttons. Covers where the items come from, what a paginator takes, and rendering each item.
A list longer than one message needs a Next button, and that button has to know which page comes next. If you keep the page number in memory, every button stops working the next time your bot restarts. If you write it into the id by hand, the bounds checks and disabled buttons are yours to write too.
A Paginator sends one page of a list and routes the clicks on its nav buttons. Every button carries its target page inside the custom id, so a click still works after your bot restarts.
import {
class ArraySource<Item>ArraySource,
function ButtonRoute<const Defs extends readonly AnyCustomId[]>(
...defs: Defs
): <TCtor extends AnyHandlerCtor>(
constructor: AssertComponentRoute<InteractionKind.Button, Defs, TCtor>
) => void
ButtonRoute,
class Paginator<Item, const Prefix extends string>Paginator,
class SlashHandler<
Route extends keyof SlashRegistry,
Cache extends CacheType = CacheFor<Route>
>
SlashHandler,
function SlashRoute<const Route extends keyof SlashRegistry>(
...routes: Route[]
): <TCtor extends AnyHandlerCtor>(
constructor: AssertSlashRoute<Route, TCtor>
) => void
SlashRoute
} from '@seedcord/gateway';
export const const Roles: Paginator<Role, "roles">Roles = new new Paginator<Role, "roles">(config: PaginatorConfig<Role, "roles", PageContext>): Paginator<Role, "roles">Paginator({
PaginatorConfig<Role, "roles", PageContext>.prefix: "roles"prefix: 'roles',
PaginatorConfig<Role, "roles", PageContext>.source: PageSourceBase<Role, PageContext>source: new new ArraySource<Role>(load: (ctx: PageContext) => Promisable<readonly Role[]>, opts?: {
perPage?: number;
}): ArraySource<Role>
ArraySource(
(ctx: PageContextctx) => [...(ctx: PageContextctx.PageContext.guild: Guild | nullguild?.Guild.roles: RoleManagerroles.DataManager<string, Role, RoleResolvable>.cache: Collection<string, Role>cache.Map<string, Role>.values(): MapIterator<Role>values() ?? [])],
{ perPage?: numberperPage: 15 }
),
PaginatorConfig<Role, "roles", PageContext>.renderItem?: ItemRender<Role>renderItem: (role: Rolerole, index: numberindex) => `${index: numberindex + 1}. ${role: Rolerole.Role.name: stringname}`,
PaginatorConfig<Item, Prefix extends string, Ctx>.ephemeral?: booleanephemeral: true
});
@ButtonRoute<readonly [PageCursor<"roles">]>(defs_0: PageCursor<"roles">): <TCtor>(constructor: AssertComponentRoute<InteractionKind.Button, readonly [PageCursor<"roles">], TCtor>) => voidButtonRoute(const Roles: Paginator<Role, "roles">Roles.PaginatorBase<Role, "roles", PageContext>.cursor: PageCursor<"roles">cursor)
export class class RolesNavRolesNav extends const Roles: Paginator<Role, "roles">Roles.Paginator<Role, "roles">.Handler: PaginatorHandlerCtor<"roles">Handler {}
@SlashRoute<"roles">(...routes: "roles"[]): <TCtor>(constructor: AssertSlashRoute<"roles", TCtor>) => voidSlashRoute('roles')
export class class RolesListRolesList extends class SlashHandler<
Route extends keyof SlashRegistry,
Cache extends CacheType = CacheFor<Route>
>
SlashHandler<'roles'> {
public async RolesList.execute(): Promise<void>execute(): interface Promise<T>Promise<void> {
await const Roles: Paginator<Role, "roles">Roles.Paginator<Role, "roles">.start(handler: RepliableHandler<Repliables>, n?: number): Promise<SentMessage>start(this);
}
}prefix names the route every nav button carries, the same way a CustomId prefix names one. A click routes by its prefix alone, so pick a name nothing else in your bot uses.
The paginator builds its own nav handler and puts it on Roles.Handler. Extend that with an empty body, hand Roles.cursor to @ButtonRoute, and export it from wherever seedcord scans your handlers. Clicks don't route until that class exists.
start() sends the first page through your handler's reply surface, which picks reply, edit, or follow-up from the interaction's current state.
The first page is page 0, so Roles.start(this, 3) opens the fourth page. If the number is higher than the last page, ArraySource opens the last page.
Gateway and http differ
start() resolves to a discord.js Message on gateway and an APIMessage on http.
page() builds the same message and hands it back without sending. Its page number is required, as in Roles.page(this, 0). You can pass the result to this.reply() or this.followUp() as is, since seedcord's reply methods add the Components V2 flag. If you send it another way, like discord.js's channel.send() or your own REST call, add flags: MessageFlags.IsComponentsV2.
Where the items come from
ArraySource takes a loader and a page size. Every click runs the loader again, then slices out the page its button targets. It loads everything, so the page count is exact. That gives the nav row a working Last button, and the indicator reads Page 2 of 4. The cost is a full read of the list on every click, which Sources avoids by fetching one page at a time.
Your loader can return a promise. If it calls a database or an API, cache the result inside it.
perPage defaults to 10.
Gateway and http differ
The loader receives a PageContext, which carries guild: Guild | null on gateway and guildId: string | null on http. Reading ctx.guild fails to compile there. Fetch the guild through ctx.core.rest where your loader reads guild data.
What a paginator takes
| key | what it does |
|---|---|
prefix | the route prefix on every nav button |
source | where one page of items comes from |
renderItem | turns one item into a line, String(item) when left out, ignored once render is set |
render | replaces the whole page, covered in Custom rendering |
ephemeral | whether the first page is ephemeral, false by default |
Every nav click edits the message start() sent. One ephemeral covers the whole run.
Rendering each item
Without render, seedcord puts the items and a row of nav buttons in one container.
renderItem receives an item and its position, counted from the start of the list. The index counts from zero, so at a perPage of 15 the first item on page two gets 15. The Roles sample adds one to print it as 16.
Return a string from renderItem for a plain line. For an item that carries a thumbnail or a button of its own, return a BuilderComponent<'section'>.
import {
ArraySource,
BuilderComponent,
Paginator
} from '@seedcord/gateway';
export const Reports = new Paginator({
prefix: 'reports',
source: new ArraySource(() => loadReports()),
renderItem: (report, index) => new ReportRow(report, index + 1)
});Return ReportRow whole, since seedcord reads .component from it.
One page can mix both forms. Each ReportRow breaks the run of text lines and renders as its own block, in the same order as your items.
Three more pages continue from here. Each one hands you a job seedcord was doing.
- Sources pages a list too large to load whole, and writes a source of its own.
- Custom rendering replaces the message, nav buttons included.
- Paging it yourself drops the paginator and keeps the page math.