├── .npmrc ├── .gitattributes ├── example ├── .gitignore ├── config.js ├── package.json ├── src │ ├── commands │ │ ├── ButtonPagination.js │ │ ├── ReactionPagination.js │ │ ├── SelectPagination.js │ │ ├── CustomReactionPagination.js │ │ ├── CustomSelectPagination.js │ │ ├── CustomButtonPagination.js │ │ ├── DynamicButtonPagination.js │ │ ├── ActionRowPagination.js │ │ └── AdvancedPagination.js │ ├── util │ │ ├── Constants.js │ │ └── PokeAPI.js │ └── index.js └── README.md ├── .npmignore ├── .prettierrc.json ├── src ├── util │ ├── PaginatorEvents.js │ ├── SelectPaginatorOptions.js │ ├── BaseOptions.js │ ├── ActionRowPaginatorOptions.js │ ├── ButtonPaginatorOptions.js │ └── ReactionPaginatorOptions.js ├── index.js └── structures │ ├── SelectPaginator.js │ ├── ReactionPaginator.js │ ├── ButtonPaginator.js │ ├── ActionRowPaginator.js │ └── BasePaginator.js ├── LICENSE ├── .gitignore ├── package.json ├── .eslintrc.json └── README.md /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | node_modules 3 | package-lock.json 4 | config.js -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | example 3 | .git* 4 | .vscode 5 | .eslintrc.json 6 | .prettierrc.json 7 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "printWidth": 120, 4 | "trailingComma": "all", 5 | "endOfLine": "lf", 6 | "arrowParens": "avoid" 7 | } 8 | -------------------------------------------------------------------------------- /example/config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | BOT_TOKEN: 'YOUR BOT_TOKEN HERE', 5 | CLIENT_ID: 'YOUR CLIENT ID HERE', 6 | GUILD_ID: 'YOUR GUILD ID HERE', 7 | }; 8 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "src/index.js", 3 | "scripts": { 4 | "start": "node ." 5 | }, 6 | "dependencies": { 7 | "@discordjs/builders": "^0.4.0", 8 | "@discordjs/rest": "*", 9 | "discord-api-types": "^0.22.0", 10 | "discord.js": "13.2.0", 11 | "node-fetch": "^2.6.1" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/util/PaginatorEvents.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | BEFORE_PAGE_CHANGED: 'beforePageChanged', 5 | COLLECT_ERROR: 'collectError', 6 | PAGE_CHANGED: 'pageChanged', 7 | PAGE_UNCHANGED: 'pageUnchanged', 8 | PAGINATION_END: 'paginationEnd', 9 | PAGINATION_READY: 'paginationReady', 10 | COLLECT_START: 'collectStart', 11 | COLLECT_END: 'collectEnd', 12 | }; 13 | -------------------------------------------------------------------------------- /example/src/commands/ButtonPagination.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { SlashCommandBuilder } = require('@discordjs/builders'); 4 | const { ButtonPaginator } = require('../../../src'); 5 | const { pages } = require('../util/Constants'); 6 | 7 | module.exports = { 8 | data: new SlashCommandBuilder() 9 | .setName('button-pagination') 10 | .setDescription('Replies with a button based pagination!'), 11 | async execute(interaction) { 12 | const buttonPaginator = new ButtonPaginator(interaction, { pages }); 13 | await buttonPaginator.send(); 14 | return buttonPaginator.message; 15 | }, 16 | }; 17 | -------------------------------------------------------------------------------- /src/util/SelectPaginatorOptions.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const ActionRowPaginatorOptions = require('./ActionRowPaginatorOptions'); 4 | 5 | class SelectPaginatorOptions extends ActionRowPaginatorOptions { 6 | static createDefault() { 7 | return { 8 | messageActionRows: [ 9 | { 10 | components: [ 11 | { 12 | type: 'SELECT_MENU', 13 | }, 14 | ], 15 | }, 16 | ], 17 | identifiersResolver: ({ interaction, paginator }) => { 18 | const [selectedValue] = interaction.values; 19 | return { ...paginator.currentIdentifiers, pageIdentifier: parseInt(selectedValue) }; 20 | }, 21 | }; 22 | } 23 | } 24 | 25 | module.exports = SelectPaginatorOptions; 26 | -------------------------------------------------------------------------------- /src/util/BaseOptions.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | class BaseOptions extends null { 4 | static createDefault() { 5 | return { 6 | maxPageCache: 100, 7 | messageSender: ({ interaction, messageOptions }) => interaction.editReply(messageOptions), 8 | initialIdentifiers: { pageIdentifier: 0 }, 9 | shouldChangePage: ({ newIdentifiers, currentIdentifiers, paginator }) => 10 | paginator.message.deletable && newIdentifiers.pageIdentifier !== currentIdentifiers.pageIdentifier, 11 | useCache: true, 12 | collectorOptions: { 13 | filter: ({ interaction, paginator }) => interaction.user === paginator.user && !interaction.user.bot, 14 | idle: 6e4, 15 | }, 16 | }; 17 | } 18 | } 19 | 20 | module.exports = BaseOptions; 21 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | BasePaginator: require('./structures/BasePaginator'), 5 | BaseOptions: require('./util/BaseOptions'), 6 | ReactionPaginator: require('./structures/ReactionPaginator'), 7 | ReactionPaginatorOptions: require('./util/ReactionPaginatorOptions'), 8 | ActionRowPaginator: require('./structures/ActionRowPaginator'), 9 | ActionRowPaginatorOptions: require('./util/ActionRowPaginatorOptions'), 10 | ButtonPaginator: require('./structures/ButtonPaginator'), 11 | ButtonPaginatorOptions: require('./util/ButtonPaginatorOptions'), 12 | SelectPaginator: require('./structures/SelectPaginator'), 13 | SelectPaginatorOptions: require('./util/SelectPaginatorOptions'), 14 | PaginatorEvents: require('./util/PaginatorEvents'), 15 | }; 16 | -------------------------------------------------------------------------------- /example/src/commands/ReactionPagination.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { SlashCommandBuilder } = require('@discordjs/builders'); 4 | const { PaginatorEvents, ReactionPaginator } = require('../../../src'); 5 | const { basicErrorHandler, pages, basicEndHandler } = require('../util/Constants'); 6 | 7 | module.exports = { 8 | data: new SlashCommandBuilder() 9 | .setName('react-pagination') 10 | .setDescription('Replies with a reaction based pagination!'), 11 | async execute(interaction) { 12 | const reactionPaginator = new ReactionPaginator(interaction, { pages }) 13 | .on(PaginatorEvents.COLLECT_ERROR, basicErrorHandler) 14 | .on(PaginatorEvents.PAGINATION_END, basicEndHandler); 15 | await reactionPaginator.send(); 16 | return reactionPaginator.message; 17 | }, 18 | }; 19 | -------------------------------------------------------------------------------- /src/util/ActionRowPaginatorOptions.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { Util } = require('discord.js'); 4 | const BaseOptions = require('./BaseOptions'); 5 | 6 | class ActionRowPaginatorOptions extends BaseOptions { 7 | static createDefault() { 8 | return Util.mergeDefault(BaseOptions.createDefault(), { 9 | customIdPrefix: 'paginator', 10 | messageActionRows: [ 11 | { 12 | type: 'ACTION_ROW', 13 | components: [], 14 | }, 15 | ], 16 | collectorOptions: { 17 | filter: ({ interaction, paginator }) => 18 | interaction.isMessageComponent() && 19 | interaction.component.customId.startsWith(paginator.customIdPrefix) && 20 | interaction.user === paginator.user && 21 | !interaction.user.bot, 22 | }, 23 | }); 24 | } 25 | } 26 | 27 | module.exports = ActionRowPaginatorOptions; 28 | -------------------------------------------------------------------------------- /example/src/util/Constants.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { MessageEmbed } = require('discord.js'); 4 | 5 | const myPages = []; 6 | for (let i = 0; i < 10; i++) { 7 | const pageEmbed = new MessageEmbed(); 8 | pageEmbed.setTitle(`This embed is index ${i}!`).setDescription(`That means it is page #${i + 1}`); 9 | pageEmbed.setFooter(`Page ${i + 1} / 10`); 10 | myPages.push(pageEmbed); 11 | } 12 | 13 | module.exports.pages = myPages; 14 | 15 | module.exports.basicErrorHandler = ({ error }) => console.log(error); 16 | module.exports.basicEndHandler = async ({ reason, paginator }) => { 17 | // This is a basic handler that will delete the message containing the pagination. 18 | try { 19 | console.log(`The pagination has ended: ${reason}`); 20 | if (paginator.message.deletable) await paginator.message.delete(); 21 | } catch (error) { 22 | console.log('There was an error when deleting the message: '); 23 | console.log(error); 24 | } 25 | }; 26 | -------------------------------------------------------------------------------- /example/src/commands/SelectPagination.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { SlashCommandBuilder } = require('@discordjs/builders'); 4 | const { PaginatorEvents, SelectPaginator } = require('../../../src'); 5 | const { basicEndHandler, basicErrorHandler, pages } = require('../util/Constants'); 6 | 7 | module.exports = { 8 | data: new SlashCommandBuilder() 9 | .setName('select-pagination') 10 | .setDescription('Replies with a select menu based pagination!'), 11 | async execute(interaction) { 12 | const selectOptions = []; 13 | for (let i = 0; i < 10; i++) { 14 | selectOptions.push({ 15 | label: `"Page #${i + 1}`, 16 | value: `${i}`, 17 | description: `This will take you to page #${i + 1}`, 18 | }); 19 | } 20 | const selectPaginator = new SelectPaginator(interaction, { 21 | pages, 22 | selectOptions: selectOptions, 23 | }) 24 | .on(PaginatorEvents.COLLECT_ERROR, basicErrorHandler) 25 | .on(PaginatorEvents.PAGINATION_END, basicEndHandler); 26 | await selectPaginator.send(); 27 | return selectPaginator.message; 28 | }, 29 | }; 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Saanu Reghunadh 4 | Copyright (c) 2021 Psibean 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /src/structures/SelectPaginator.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { Util } = require('discord.js'); 4 | const ActionRowPaginator = require('./ActionRowPaginator'); 5 | const SelectPaginatorOptions = require('../util/SelectPaginatorOptions'); 6 | 7 | class SelectPaginator extends ActionRowPaginator { 8 | constructor(interaction, options) { 9 | super(interaction, Util.mergeDefault(SelectPaginatorOptions.createDefault(), options)); 10 | if (this.options.selectOptions) { 11 | const actionRows = [[]]; 12 | this.options.selectOptions.forEach(selectOption => { 13 | const rowIndex = selectOption.row ? selectOption.row : 0; 14 | if (rowIndex >= 0 && rowIndex < actionRows.length) { 15 | actionRows[rowIndex].push(selectOption); 16 | } else { 17 | actionRows[0].push(selectOption); 18 | } 19 | }); 20 | actionRows.forEach((selectOptions, index) => { 21 | this.messageActionRows[index].components[0].addOptions(selectOptions); 22 | }); 23 | } 24 | } 25 | 26 | getSelectMenu(row = 0) { 27 | return this.getMessageActionRow(row).components[0]; 28 | } 29 | } 30 | 31 | module.exports = SelectPaginator; 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # next.js build output 61 | .next 62 | 63 | # vscode 64 | .vscode/ 65 | 66 | # Autogenerated 67 | src/index.mjs -------------------------------------------------------------------------------- /src/util/ButtonPaginatorOptions.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const ActionRowPaginatorOptions = require('./ActionRowPaginatorOptions'); 4 | 5 | class ButtonPaginatorOptions extends ActionRowPaginatorOptions { 6 | static createDefault() { 7 | return { 8 | customIdPrefix: 'paginator', 9 | buttons: [ 10 | { 11 | label: 'Previous', 12 | }, 13 | { 14 | label: 'Next', 15 | }, 16 | ], 17 | identifiersResolver: ({ interaction, paginator }) => { 18 | const val = interaction.component.label.toLowerCase(); 19 | let { pageIdentifier } = paginator.currentIdentifiers; 20 | if (val === 'previous') pageIdentifier -= 1; 21 | else if (val === 'next') pageIdentifier += 1; 22 | // The default identifier is a cyclic index. 23 | if (pageIdentifier < 0) { 24 | pageIdentifier = paginator.maxNumberOfPages + (pageIdentifier % paginator.maxNumberOfPages); 25 | } else if (pageIdentifier >= paginator.maxNumberOfPages) { 26 | pageIdentifier %= paginator.maxNumberOfPages; 27 | } 28 | return { ...paginator.currentIdentifiers, pageIdentifier }; 29 | }, 30 | }; 31 | } 32 | } 33 | 34 | module.exports = ButtonPaginatorOptions; 35 | -------------------------------------------------------------------------------- /src/structures/ReactionPaginator.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { Util } = require('discord.js'); 4 | const BasePaginator = require('./BasePaginator'); 5 | const ReactionPaginatorOptions = require('../util/ReactionPaginatorOptions'); 6 | 7 | class ReactionPaginator extends BasePaginator { 8 | constructor(interaction, options) { 9 | super(interaction, Util.mergeDefault(ReactionPaginatorOptions.createDefault(), options)); 10 | 11 | if (typeof options.emojiList === 'undefined' || options.emojiList.length === 0) { 12 | throw new Error('emojiList is undefined or empty, must be a list of EmojiResolvables'); 13 | } 14 | 15 | this.emojiList = this.options.emojiList; 16 | } 17 | 18 | _createCollector() { 19 | return this.message.createReactionCollector(this.collectorOptions); 20 | } 21 | 22 | getCollectorArgs(args) { 23 | const [reaction, user] = args; 24 | return super.getCollectorArgs({ reaction, user }); 25 | } 26 | 27 | async _postSetup() { 28 | // eslint-disable-next-line no-await-in-loop 29 | for (const emoji of this.emojiList) await this.message.react(emoji); 30 | super._postSetup(); 31 | } 32 | 33 | async _collectStart(args) { 34 | super._collectStart(args); 35 | await args.reaction.users.remove(args.user); 36 | } 37 | } 38 | 39 | module.exports = ReactionPaginator; 40 | -------------------------------------------------------------------------------- /src/structures/ButtonPaginator.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { Util } = require('discord.js'); 4 | const ActionRowPaginator = require('./ActionRowPaginator'); 5 | const ButtonPaginatorOptions = require('../util/ButtonPaginatorOptions'); 6 | 7 | class ButtonPaginator extends ActionRowPaginator { 8 | constructor(interaction, options) { 9 | super(interaction, Util.mergeDefault(ButtonPaginatorOptions.createDefault(), options)); 10 | // Buttons may also be set via the messageActionRows prop. 11 | if (this.options.buttons) { 12 | const buttonRows = [[]]; 13 | for (const button of this.options.buttons) { 14 | button.type = 'BUTTON'; 15 | const isLink = button.url !== undefined; 16 | if (!isLink) { 17 | button.customId = button.customId 18 | ? this._generateCustomId(button.customId) 19 | : this._generateCustomId(button.label); 20 | } 21 | // LINK : PRIMARY - MessageButtonStyles doesn't work. 22 | if (!button.style) button.style = isLink ? 5 : 1; 23 | if (button.row > 0 && button.row < buttonRows.length) { 24 | buttonRows[button.row].push(button); 25 | } else { 26 | buttonRows[0].push(button); 27 | } 28 | } 29 | buttonRows.forEach((row, index) => { 30 | this.messageActionRows[index].addComponents(row); 31 | }); 32 | } 33 | } 34 | } 35 | 36 | module.exports = ButtonPaginator; 37 | -------------------------------------------------------------------------------- /src/util/ReactionPaginatorOptions.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { Util } = require('discord.js'); 4 | const BaseOptions = require('./BaseOptions'); 5 | 6 | class ReactionPaginatorOptions extends BaseOptions { 7 | static createDefault() { 8 | return Util.mergeDefault(BaseOptions.createDefault(), { 9 | emojiList: ['⏪', '⏩'], 10 | collectorOptions: { 11 | filter: ({ reaction, user, paginator }) => 12 | user === paginator.user && paginator.emojiList.includes(reaction.emoji.name) && !user.bot, 13 | }, 14 | identifiersResolver: ({ reaction, paginator }) => { 15 | let { pageIdentifier } = paginator.currentIdentifiers; 16 | switch (reaction.emoji.name) { 17 | case paginator.emojiList[0]: 18 | pageIdentifier -= 1; 19 | break; 20 | case paginator.emojiList[1]: 21 | pageIdentifier += 1; 22 | break; 23 | } 24 | // The default identifier is a cyclic index. 25 | if (pageIdentifier < 0) { 26 | pageIdentifier = paginator.maxNumberOfPages + (pageIdentifier % paginator.maxNumberOfPages); 27 | } else if (pageIdentifier >= paginator.maxNumberOfPages) { 28 | pageIdentifier %= paginator.maxNumberOfPages; 29 | } 30 | return { ...paginator.currentIdentifiers, pageIdentifier }; 31 | }, 32 | }); 33 | } 34 | } 35 | 36 | module.exports = ReactionPaginatorOptions; 37 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@psibean/discord.js-pagination", 3 | "version": "4.0.5", 4 | "description": "A simple utility to paginate discord embeds based on discord.js-pagination.", 5 | "main": "./src/index.js", 6 | "module": "./src/index.mjs", 7 | "files": [ 8 | "src" 9 | ], 10 | "exports": { 11 | "./*": "./*", 12 | ".": { 13 | "require": "./src/index.js", 14 | "import": "./src/index.mjs" 15 | } 16 | }, 17 | "scripts": { 18 | "lint": "eslint src", 19 | "lint:example": "eslint example", 20 | "lint:fix": "eslint src --fix", 21 | "lint:fix:example": "eslint example --fix", 22 | "prepublishOnly": "gen-esm-wrapper ./src/index.js ./src/index.mjs", 23 | "test": "echo \"Error: no test specified\" && exit 1" 24 | }, 25 | "repository": { 26 | "type": "git", 27 | "url": "git+https://github.com/psibean/discord.js-pagination.git" 28 | }, 29 | "keywords": [ 30 | "discord.js", 31 | "pagination" 32 | ], 33 | "author": "psibean", 34 | "license": "MIT", 35 | "bugs": { 36 | "url": "https://github.com/psibean/discord.js-pagination/issues" 37 | }, 38 | "homepage": "https://github.com/psibean/discord.js-pagination#readme", 39 | "devDependencies": { 40 | "discord.js": "^13.6.0", 41 | "eslint": "^7.32.0", 42 | "eslint-config-prettier": "^8.3.0", 43 | "eslint-plugin-import": "^2.23.4", 44 | "eslint-plugin-prettier": "^3.4.0", 45 | "gen-esm-wrapper": "^1.1.2", 46 | "prettier": "^2.3.2" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /example/src/util/PokeAPI.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const fetch = require('node-fetch'); 3 | const pokeApiUrl = 'https://pokeapi.co/api/v2/'; 4 | 5 | module.exports.constructPokemonOptions = pokemonApiResponse => { 6 | const pokemonOptions = []; 7 | pokemonApiResponse.forEach(pokemon => { 8 | const splitPokemonUrl = pokemon.url.split('/'); 9 | const pokemonNumber = splitPokemonUrl[splitPokemonUrl.length - 2]; 10 | pokemonOptions.push({ 11 | label: `#${`${pokemonNumber}`.padStart(3, '0')} - ${pokemon.name}`, 12 | value: pokemon.name, 13 | }); 14 | }); 15 | return pokemonOptions; 16 | }; 17 | 18 | module.exports.PokeAPI = { 19 | getType: type => fetch(`${pokeApiUrl}type/${type}`).then(res => res.json()), 20 | getTypes: () => 21 | fetch(`${pokeApiUrl}type/`) 22 | .then(res => res.json()) 23 | .then(jsonData => jsonData.results), 24 | getPokemonOfType: type => fetch(`${pokeApiUrl}type/${type}`).then(res => res.json()), 25 | getPokemonListOfType: async (type, start, end) => { 26 | const pokemonTypeResponse = await fetch(`${pokeApiUrl}type/${type}`).then(res => res.json()); 27 | return pokemonTypeResponse.pokemon.slice(start, end).map(entry => entry.pokemon); 28 | }, 29 | getPokemon: name => fetch(`${pokeApiUrl}pokemon/${name}`).then(res => res.json()), 30 | getAllPokemon: () => fetch(`${pokeApiUrl}pokemon/`).then(res => res.json()), 31 | getPokemonList: (limit, offset) => 32 | fetch(`${pokeApiUrl}pokemon?limit=${limit}&offset=${offset}`) 33 | .then(res => res.json()) 34 | .then(jsonData => jsonData.results), 35 | }; 36 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | # Example Paginator Bot 2 | 3 | This is just a quick example to demonstrate how the paginator works. 4 | 5 | ## Setup 6 | 7 | ``` 8 | npm install 9 | ``` 10 | Update the `config.js` file with your own bot token, client id and guild id. 11 | ``` 12 | npm run start 13 | ``` 14 | ### ReactionPaginator 15 | 16 | 17 | 18 | ### SelectPaginator 19 | 20 | 21 | 22 | ## Usage 23 | 24 | The following commands are available as demonstrations: 25 | 26 | ### /advanced-pagination 27 | This is an action row pagination example that uses two select menus and a row of buttons for navigation. It makes API calls to dynamically create page content. 28 | 29 | ![advanced-pagination-gif](https://i.imgur.com/n9O0OH1.gif) 30 | ### /action-row-pagination 31 | This is an action row paghination example that uses both buttons and a select menu for navigation. It makes API calls to dynamically create page content. 32 | 33 | ![action-row-pagination-gif](https://imgur.com/hidL14k.gif) 34 | ### /custom-react-pagination 35 | A customised reaction example. 36 | 37 | ![react-paginator-gif](https://imgur.com/A9Wj2fX.gif) 38 | ### /custom-button-pagination 39 | A customised button example. 40 | 41 | ![button-paginator-gif](https://imgur.com/Esqo43Z.gif) 42 | ### /custom-select-pagination 43 | A customised select example. 44 | 45 | ![select-paginator-gif](https://imgur.com/lgNuRWC.gif) 46 | ### /dynamic-button-pagination 47 | A dynamic button example where the pages are not provided up front. 48 | ### /react-pagination 49 | A basic reaction example. 50 | ### /button-pagination 51 | A basic button example. 52 | ### /select-pagination 53 | A basic select example. -------------------------------------------------------------------------------- /example/src/commands/CustomReactionPagination.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { SlashCommandBuilder } = require('@discordjs/builders'); 4 | const { PaginatorEvents, ReactionPaginator } = require('../../../src'); 5 | const { basicErrorHandler, basicEndHandler, pages } = require('../util/Constants'); 6 | 7 | module.exports = { 8 | data: new SlashCommandBuilder() 9 | .setName('custom-react-pagination') 10 | .setDescription('Replies with a custom reaction based pagination!'), 11 | async execute(interaction) { 12 | const emojiList = ['⏪', '❌', '⏩']; 13 | const identifiersResolver = async ({ reaction, paginator }) => { 14 | let { pageIdentifier = 0 } = paginator.currentIdentifiers; 15 | switch (reaction.emoji.name) { 16 | case paginator.emojiList[0]: 17 | pageIdentifier -= 1; 18 | break; 19 | case paginator.emojiList[1]: 20 | await paginator.message.delete(); 21 | break; 22 | case paginator.emojiList[2]: 23 | pageIdentifier += 1; 24 | break; 25 | } 26 | 27 | if (pageIdentifier < 0) { 28 | pageIdentifier = paginator.maxNumberOfPages + (pageIdentifier % paginator.maxNumberOfPages); 29 | } else if (pageIdentifier >= paginator.maxNumberOfPages) { 30 | pageIdentifier %= paginator.maxNumberOfPages; 31 | } 32 | return { ...paginator.currentIdentifiers, pageIdentifier }; 33 | }; 34 | const reactionPaginator = new ReactionPaginator(interaction, { 35 | pages, 36 | emojiList, 37 | identifiersResolver, 38 | }) 39 | .on(PaginatorEvents.COLLECT_ERROR, basicErrorHandler) 40 | .on(PaginatorEvents.PAGINATION_END, basicEndHandler); 41 | await reactionPaginator.send(); 42 | return reactionPaginator.message; 43 | }, 44 | }; 45 | -------------------------------------------------------------------------------- /example/src/commands/CustomSelectPagination.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { SlashCommandBuilder } = require('@discordjs/builders'); 4 | const { PaginatorEvents, SelectPaginator } = require('../../../src'); 5 | const { pages, basicEndHandler, basicErrorHandler } = require('../util/Constants'); 6 | 7 | module.exports = { 8 | data: new SlashCommandBuilder() 9 | .setName('custom-select-pagination') 10 | .setDescription('Replies with a select menu based pagination!'), 11 | async execute(interaction) { 12 | const selectOptions = []; 13 | for (let i = 0; i < 10; i++) { 14 | selectOptions.push({ 15 | label: `"Page #${i + 1}`, 16 | value: `${i}`, 17 | description: `This will take you to page #${i + 1}`, 18 | }); 19 | } 20 | const selectPaginator = new SelectPaginator(interaction, { 21 | pages, 22 | selectOptions: selectOptions, 23 | messageActionRows: [ 24 | { 25 | components: [ 26 | { 27 | disabled: true, 28 | placeholder: "You're now on page #1", 29 | }, 30 | ], 31 | }, 32 | ], 33 | }) 34 | .on(PaginatorEvents.BEFORE_PAGE_CHANGED, ({ newIdentifiers, paginator }) => { 35 | // Here we use the BEFORE_PAGE_CHANGED event to update the placeholder text 36 | paginator.getSelectMenu().placeholder = `You're now on page #${newIdentifiers.pageIdentifier + 1}`; 37 | }) 38 | .on(PaginatorEvents.PAGINATION_READY, async paginator => { 39 | selectPaginator.getSelectMenu().disabled = false; 40 | await paginator.message.edit(paginator.currentPage); 41 | }) 42 | .on(PaginatorEvents.COLLECT_ERROR, basicErrorHandler) 43 | .on(PaginatorEvents.PAGINATION_END, basicEndHandler); 44 | await selectPaginator.send(); 45 | return selectPaginator.message; 46 | }, 47 | }; 48 | -------------------------------------------------------------------------------- /example/src/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | const { Client, Intents, Collection } = require('discord.js'); 6 | const config = require('../config'); 7 | 8 | process.on('uncaughtException', err => { 9 | console.log('UNCAUGHT EXCEPTION:\n'); 10 | console.log(err); 11 | }); 12 | 13 | const client = new Client({ 14 | intents: [Intents.FLAGS.GUILDS, Intents.FLAGS.GUILD_MESSAGES, Intents.FLAGS.GUILD_MESSAGE_REACTIONS], 15 | }); 16 | 17 | client.commands = new Collection(); 18 | const commandsPath = path.join(__dirname, 'commands'); 19 | const commandFiles = fs.readdirSync(commandsPath).filter(file => file.endsWith('.js')); 20 | const commands = []; 21 | 22 | for (const file of commandFiles) { 23 | const command = require(path.join(commandsPath, file)); 24 | // Set a new item in the Collection 25 | // with the key as the command name and the value as the exported module 26 | client.commands.set(command.data.name, command); 27 | commands.push(command.data.toJSON()); 28 | } 29 | 30 | client.once('ready', () => { 31 | console.log('Ready!'); 32 | try { 33 | console.log('Started refreshing application (/) commands.'); 34 | for (const guild of client.guilds.cache.values()) { 35 | guild.commands 36 | .fetch() 37 | .then(guildCommands => { 38 | if (guildCommands.size === 0) { 39 | return guild.commands.set(commands); 40 | } else { 41 | for (const command of commands) { 42 | if (!guildCommands.some(val => val.name === command.name)) { 43 | guild.commands.create(command).then(createdCommand => { 44 | console.log(`Created ${createdCommand.name} for ${guild.id}`); 45 | }); 46 | } 47 | } 48 | 49 | return guildCommands; 50 | } 51 | }) 52 | .catch(error => { 53 | console.log(`Error while fetching guild commands... ${guild.id}`); 54 | console.log(error); 55 | }); 56 | } 57 | 58 | console.log('Successfully reloaded application (/) commands.'); 59 | } catch (error) { 60 | console.error(error); 61 | } 62 | }); 63 | 64 | client.on('interactionCreate', async interaction => { 65 | if (!interaction.isCommand()) return; 66 | 67 | const { commandName } = interaction; 68 | 69 | if (!client.commands.has(commandName)) return; 70 | 71 | try { 72 | await interaction.deferReply({ ephemeral: true }); 73 | await client.commands.get(commandName).execute(interaction); 74 | } catch (error) { 75 | console.error(error); 76 | await interaction.reply({ content: 'There was an error while executing this command!', ephemeral: true }); 77 | } 78 | }); 79 | 80 | client.login(config.BOT_TOKEN); 81 | -------------------------------------------------------------------------------- /example/src/commands/CustomButtonPagination.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { SlashCommandBuilder } = require('@discordjs/builders'); 4 | const { PaginatorEvents, ButtonPaginator } = require('../../../src'); 5 | const { basicEndHandler, basicErrorHandler, pages } = require('../util/Constants'); 6 | 7 | module.exports = { 8 | data: new SlashCommandBuilder() 9 | .setName('custom-button-pagination') 10 | .setDescription('Replies with a button based pagination!'), 11 | async execute(interaction) { 12 | const buttons = [ 13 | { 14 | label: 'First', 15 | emoji: '⏪', 16 | style: 'SECONDARY', 17 | disabled: true, 18 | }, 19 | { 20 | label: 'Previous', 21 | disabled: true, 22 | }, 23 | { 24 | label: 'Delete', 25 | style: 'DANGER', 26 | disabled: true, 27 | }, 28 | { 29 | label: 'Next', 30 | disabled: true, 31 | }, 32 | { 33 | label: 'Last', 34 | emoji: '⏩', 35 | style: 'SECONDARY', 36 | disabled: true, 37 | }, 38 | ]; 39 | 40 | // eslint-disable-next-line no-shadow 41 | const identifiersResolver = async ({ interaction, paginator }) => { 42 | const val = interaction.component.label.toLowerCase(); 43 | let { pageIdentifier } = paginator.currentIdentifiers; 44 | switch (val) { 45 | case 'first': 46 | return paginator.initialIdentifiers; 47 | case 'next': 48 | pageIdentifier += 1; 49 | break; 50 | case 'delete': 51 | await paginator.message.delete(); 52 | break; 53 | case 'previous': 54 | pageIdentifier -= 1; 55 | break; 56 | case 'last': 57 | pageIdentifier = paginator.maxNumberOfPages - 1; 58 | } 59 | 60 | if (pageIdentifier < 0) { 61 | pageIdentifier = paginator.maxNumberOfPages + (pageIdentifier % paginator.maxNumberOfPages); 62 | } else if (pageIdentifier >= paginator.maxNumberOfPages) { 63 | pageIdentifier %= paginator.maxNumberOfPages; 64 | } 65 | return { ...paginator.currentIdentifiers, pageIdentifier }; 66 | }; 67 | 68 | const buttonPaginator = new ButtonPaginator(interaction, { 69 | pages, 70 | buttons, 71 | identifiersResolver, 72 | }) 73 | .on(PaginatorEvents.PAGINATION_READY, async paginator => { 74 | for (const actionRow of paginator.messageActionRows) { 75 | for (const button of actionRow.components) { 76 | button.disabled = false; 77 | } 78 | } 79 | await paginator.message.edit(paginator.currentPage); 80 | }) 81 | .on(PaginatorEvents.COLLECT_ERROR, basicErrorHandler) 82 | .on(PaginatorEvents.PAGINATION_END, basicEndHandler); 83 | await buttonPaginator.send(); 84 | return buttonPaginator.message; 85 | }, 86 | }; 87 | -------------------------------------------------------------------------------- /example/src/commands/DynamicButtonPagination.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { SlashCommandBuilder } = require('@discordjs/builders'); 4 | const { MessageEmbed } = require('discord.js'); 5 | const { PaginatorEvents, ButtonPaginator } = require('../../../src'); 6 | const { basicEndHandler, basicErrorHandler } = require('../util/Constants'); 7 | 8 | module.exports = { 9 | data: new SlashCommandBuilder() 10 | .setName('dynamic-button-pagination') 11 | .setDescription('Replies with a dynamic button based pagination!'), 12 | async execute(interaction) { 13 | const buttons = [ 14 | { 15 | label: 'First', 16 | emoji: '⏪', 17 | style: 'SECONDARY', 18 | disabled: true, 19 | }, 20 | { 21 | label: 'Previous', 22 | disabled: true, 23 | }, 24 | { 25 | label: 'Delete', 26 | style: 'DANGER', 27 | disabled: true, 28 | }, 29 | { 30 | label: 'Next', 31 | disabled: true, 32 | }, 33 | { 34 | label: 'Last', 35 | emoji: '⏩', 36 | style: 'SECONDARY', 37 | disabled: true, 38 | }, 39 | ]; 40 | 41 | // eslint-disable-next-line no-shadow 42 | const identifiersResolver = async ({ interaction, paginator }) => { 43 | const val = interaction.component.label.toLowerCase(); 44 | let { pageIdentifier } = paginator.currentIdentifiers; 45 | switch (val) { 46 | case 'first': 47 | return paginator.initialIdentifiers; 48 | case 'next': 49 | pageIdentifier += 1; 50 | break; 51 | case 'delete': 52 | await paginator.message.delete(); 53 | break; 54 | case 'previous': 55 | pageIdentifier -= 1; 56 | break; 57 | case 'last': 58 | pageIdentifier = paginator.maxNumberOfPages - 1; 59 | } 60 | 61 | if (pageIdentifier < 0) { 62 | pageIdentifier = paginator.maxNumberOfPages + (pageIdentifier % paginator.maxNumberOfPages); 63 | } else if (pageIdentifier >= paginator.maxNumberOfPages) { 64 | pageIdentifier %= paginator.maxNumberOfPages; 65 | } 66 | return { ...paginator.currentIdentifiers, pageIdentifier }; 67 | }; 68 | 69 | const pageEmbedResolver = ({ newIdentifiers, paginator }) => { 70 | const newPageEmbed = new MessageEmbed(); 71 | newPageEmbed 72 | .setTitle(`This embed is index ${newIdentifiers.pageIdentifier}!`) 73 | .setDescription(`That means it is page #${newIdentifiers.pageIdentifier + 1}`); 74 | newPageEmbed.setFooter(`Page ${newIdentifiers.pageIdentifier + 1} / ${paginator.maxNumberOfPages}`); 75 | return newPageEmbed; 76 | }; 77 | 78 | const buttonPaginator = new ButtonPaginator(interaction, { 79 | buttons, 80 | identifiersResolver, 81 | pageEmbedResolver, 82 | maxNumberOfPages: 10, 83 | }) 84 | .on(PaginatorEvents.PAGINATION_READY, async paginator => { 85 | for (const button of paginator.getMessageActionRow(0).components) { 86 | button.disabled = false; 87 | } 88 | await paginator.message.edit(paginator.currentPageMessageOptions); 89 | }) 90 | .on(PaginatorEvents.COLLECT_ERROR, basicErrorHandler) 91 | .on(PaginatorEvents.PAGINATION_END, basicEndHandler); 92 | await buttonPaginator.send(); 93 | return buttonPaginator.message; 94 | }, 95 | }; 96 | -------------------------------------------------------------------------------- /src/structures/ActionRowPaginator.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const { MessageActionRow, Util } = require('discord.js'); 3 | const BasePaginator = require('./BasePaginator'); 4 | const ActionRowPaginatorOptions = require('../util/ActionRowPaginatorOptions'); 5 | 6 | class ActionRowPaginator extends BasePaginator { 7 | constructor(interaction, options) { 8 | super(interaction, Util.mergeDefault(ActionRowPaginatorOptions.createDefault(), options)); 9 | 10 | if (typeof options.messageActionRows !== 'object' || options.messageActionRows.length === 0) { 11 | throw new Error('messageActionRows is not defined or is empty'); 12 | } 13 | 14 | Object.defineProperty(this, 'customIdPrefix', { value: this.options.customIdPrefix }); 15 | Object.defineProperty(this, 'customIdSuffix', { value: interaction.id }); 16 | Object.defineProperty(this, 'messageActionRows', { value: [] }); 17 | 18 | options.messageActionRows.forEach((messageActionRowData, messageRowIndex) => { 19 | messageActionRowData.type = 'ACTION_ROW'; 20 | if (messageActionRowData.components) { 21 | messageActionRowData.components.forEach(component => { 22 | const { type, customId } = component; 23 | switch (type) { 24 | case 'SELECT_MENU': 25 | component.customId = component.customId 26 | ? this._generateCustomId(component.customId) 27 | : this._generateCustomId(`select-menu-${messageRowIndex}`); 28 | break; 29 | case 'BUTTON': 30 | component.customId = component.customId 31 | ? this._generateCustomId(customId) 32 | : this._generateCustomId(component.label); 33 | if (!component.style) component.style = 'PRIMARY'; 34 | break; 35 | } 36 | }); 37 | } 38 | this.messageActionRows.push(new MessageActionRow(messageActionRowData)); 39 | }); 40 | 41 | if (this.useCache) { 42 | this.pages.forEach((value, key) => { 43 | this.pages.set(key, { ...this.messageOptionComponents, ...value }); 44 | }); 45 | } 46 | } 47 | 48 | _createCollector() { 49 | return this.message.createMessageComponentCollector(this.collectorOptions); 50 | } 51 | 52 | getCollectorArgs(args) { 53 | const [interaction] = args; 54 | return super.getCollectorArgs({ interaction }); 55 | } 56 | 57 | _collectorFilter(interaction) { 58 | if (interaction.customId.startsWith(this.customIdPrefix) && interaction.customId.endsWith(this.customIdSuffix)) { 59 | return super._collectorFilter(interaction); 60 | } 61 | return false; 62 | } 63 | 64 | async _collectStart(args) { 65 | await args.interaction.deferUpdate(); 66 | super._collectStart(args); 67 | } 68 | 69 | editMessage({ changePageArgs, messageOptions }) { 70 | return changePageArgs.collectorArgs.interaction.editReply(messageOptions); 71 | } 72 | 73 | async _resolveMessageOptions({ changePageArgs }) { 74 | const messageOptions = await super._resolveMessageOptions({ 75 | messageOptions: this.messageOptionComponents, 76 | changePageArgs, 77 | }); 78 | return messageOptions; 79 | } 80 | 81 | getMessageActionRow(row = 0) { 82 | return this.getComponent(row); 83 | } 84 | 85 | getComponent(row = 0, index = -1) { 86 | if ( 87 | typeof this.currentPageMessageOptions === 'undefined' || 88 | row < 0 || 89 | row >= this.currentPageMessageOptions.components.length 90 | ) { 91 | return null; 92 | } 93 | if (index < 0) return this.currentPageMessageOptions.components[row]; 94 | if (index >= this.currentPageMessageOptions.components[row].components.length) return null; 95 | return this.currentPageMessageOptions.components[row].components[index]; 96 | } 97 | 98 | get messageOptionComponents() { 99 | return { components: this.messageActionRows }; 100 | } 101 | 102 | _generateCustomId(label) { 103 | return `${this.customIdPrefix}-${label}-${this.customIdSuffix}`; 104 | } 105 | } 106 | 107 | module.exports = ActionRowPaginator; 108 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["eslint:recommended", "plugin:prettier/recommended"], 3 | "plugins": ["import"], 4 | "parserOptions": { 5 | "ecmaVersion": 2021 6 | }, 7 | "env": { 8 | "es2021": true, 9 | "node": true 10 | }, 11 | "rules": { 12 | "import/order": [ 13 | "error", 14 | { 15 | "groups": ["builtin", "external", "internal", "index", "sibling", "parent"], 16 | "alphabetize": { 17 | "order": "asc" 18 | } 19 | } 20 | ], 21 | "prettier/prettier": [ 22 | 2, 23 | { 24 | "printWidth": 120, 25 | "singleQuote": true, 26 | "quoteProps": "as-needed", 27 | "trailingComma": "all", 28 | "endOfLine": "lf", 29 | "arrowParens": "avoid" 30 | } 31 | ], 32 | "strict": ["error", "global"], 33 | "no-await-in-loop": "warn", 34 | "no-compare-neg-zero": "error", 35 | "no-template-curly-in-string": "error", 36 | "no-unsafe-negation": "error", 37 | "valid-jsdoc": [ 38 | "error", 39 | { 40 | "requireReturn": false, 41 | "requireReturnDescription": false, 42 | "prefer": { 43 | "return": "returns", 44 | "arg": "param" 45 | }, 46 | "preferType": { 47 | "String": "string", 48 | "Number": "number", 49 | "Boolean": "boolean", 50 | "Symbol": "symbol", 51 | "object": "Object", 52 | "function": "Function", 53 | "array": "Array", 54 | "date": "Date", 55 | "error": "Error", 56 | "null": "void" 57 | } 58 | } 59 | ], 60 | 61 | "accessor-pairs": "warn", 62 | "array-callback-return": "error", 63 | "consistent-return": "error", 64 | "curly": ["error", "multi-line", "consistent"], 65 | "dot-location": ["error", "property"], 66 | "dot-notation": "error", 67 | "eqeqeq": "error", 68 | "no-empty-function": "error", 69 | "no-floating-decimal": "error", 70 | "no-implied-eval": "error", 71 | "no-invalid-this": "error", 72 | "no-lone-blocks": "error", 73 | "no-multi-spaces": "error", 74 | "no-new-func": "error", 75 | "no-new-wrappers": "error", 76 | "no-new": "error", 77 | "no-octal-escape": "error", 78 | "no-return-assign": "error", 79 | "no-return-await": "error", 80 | "no-self-compare": "error", 81 | "no-sequences": "error", 82 | "no-throw-literal": "error", 83 | "no-unmodified-loop-condition": "error", 84 | "no-unused-expressions": "error", 85 | "no-useless-call": "error", 86 | "no-useless-concat": "error", 87 | "no-useless-escape": "error", 88 | "no-useless-return": "error", 89 | "no-void": "error", 90 | "no-warning-comments": "warn", 91 | "prefer-promise-reject-errors": "error", 92 | "require-await": "warn", 93 | "wrap-iife": "error", 94 | "yoda": "error", 95 | 96 | "no-label-var": "error", 97 | "no-shadow": "error", 98 | "no-undef-init": "error", 99 | 100 | "callback-return": "error", 101 | "getter-return": "off", 102 | "handle-callback-err": "error", 103 | "no-mixed-requires": "error", 104 | "no-new-require": "error", 105 | "no-path-concat": "error", 106 | 107 | "array-bracket-spacing": "error", 108 | "block-spacing": "error", 109 | "brace-style": ["error", "1tbs", { "allowSingleLine": true }], 110 | "capitalized-comments": ["error", "always", { "ignoreConsecutiveComments": true }], 111 | "comma-dangle": ["error", "always-multiline"], 112 | "comma-spacing": "error", 113 | "comma-style": "error", 114 | "computed-property-spacing": "error", 115 | "consistent-this": ["error", "$this"], 116 | "eol-last": "error", 117 | "func-names": "error", 118 | "func-name-matching": "error", 119 | "func-style": ["error", "declaration", { "allowArrowFunctions": true }], 120 | "key-spacing": "error", 121 | "keyword-spacing": "error", 122 | "max-depth": "error", 123 | "max-len": ["error", 120, 2], 124 | "max-nested-callbacks": ["error", { "max": 4 }], 125 | "max-statements-per-line": ["error", { "max": 2 }], 126 | "new-cap": "off", 127 | "newline-per-chained-call": ["error", { "ignoreChainWithDepth": 3 }], 128 | "no-array-constructor": "error", 129 | "no-inline-comments": "error", 130 | "no-lonely-if": "error", 131 | "no-multiple-empty-lines": ["error", { "max": 2, "maxEOF": 1, "maxBOF": 0 }], 132 | "no-new-object": "error", 133 | "no-spaced-func": "error", 134 | "no-trailing-spaces": "error", 135 | "no-unneeded-ternary": "error", 136 | "no-whitespace-before-property": "error", 137 | "nonblock-statement-body-position": "error", 138 | "object-curly-spacing": ["error", "always"], 139 | "operator-assignment": "error", 140 | "padded-blocks": ["error", "never"], 141 | "quote-props": ["error", "as-needed"], 142 | "quotes": ["error", "single", { "avoidEscape": true, "allowTemplateLiterals": true }], 143 | "semi-spacing": "error", 144 | "semi": "error", 145 | "space-before-blocks": "error", 146 | "space-before-function-paren": [ 147 | "error", 148 | { 149 | "anonymous": "never", 150 | "named": "never", 151 | "asyncArrow": "always" 152 | } 153 | ], 154 | "space-in-parens": "error", 155 | "space-infix-ops": "error", 156 | "space-unary-ops": "error", 157 | "spaced-comment": "error", 158 | "template-tag-spacing": "error", 159 | "unicode-bom": "error", 160 | 161 | "arrow-body-style": "error", 162 | "arrow-parens": ["error", "as-needed"], 163 | "arrow-spacing": "error", 164 | "no-duplicate-imports": "error", 165 | "no-useless-computed-key": "error", 166 | "no-useless-constructor": "error", 167 | "prefer-arrow-callback": "error", 168 | "prefer-numeric-literals": "error", 169 | "prefer-rest-params": "error", 170 | "prefer-spread": "error", 171 | "prefer-template": "error", 172 | "rest-spread-spacing": "error", 173 | "template-curly-spacing": "error", 174 | "yield-star-spacing": "error" 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /example/src/commands/ActionRowPagination.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { SlashCommandBuilder } = require('@discordjs/builders'); 4 | const { Collection, MessageEmbed } = require('discord.js'); 5 | const { PaginatorEvents, ActionRowPaginator } = require('../../../src'); 6 | const { basicEndHandler, basicErrorHandler } = require('../util/Constants'); 7 | const { constructPokemonOptions, PokeAPI } = require('../util/PokeAPI'); 8 | 9 | module.exports = { 10 | data: new SlashCommandBuilder() 11 | .setName('action-row-pagination') 12 | .setDescription('Replies with a dynamic advanced pagination'), 13 | async execute(interaction) { 14 | const SELECT_LIMIT = 25; 15 | const MAX_POKEMON = 151; 16 | const MAX_SELECTIONS = Math.floor(MAX_POKEMON / SELECT_LIMIT) + (MAX_POKEMON % SELECT_LIMIT > 0 ? 1 : 0); 17 | const messageActionRows = [ 18 | { 19 | components: [ 20 | { 21 | type: 'BUTTON', 22 | emoji: '⏪', 23 | label: 'start', 24 | style: 'SECONDARY', 25 | }, 26 | { 27 | type: 'BUTTON', 28 | label: `-${SELECT_LIMIT}`, 29 | style: 'PRIMARY', 30 | }, 31 | { 32 | type: 'BUTTON', 33 | label: `+${SELECT_LIMIT}`, 34 | style: 'PRIMARY', 35 | }, 36 | { 37 | type: 'BUTTON', 38 | emoji: '⏩', 39 | label: 'end', 40 | style: 'SECONDARY', 41 | }, 42 | ], 43 | }, 44 | { 45 | components: [ 46 | { 47 | type: 'SELECT_MENU', 48 | placeholder: 'Currently viewing #001 - #025', 49 | }, 50 | ], 51 | }, 52 | ]; 53 | 54 | const pokemonSelectOptions = new Collection(); 55 | const initialSelectIdentifier = 0; 56 | 57 | const initialPokemon = await PokeAPI.getPokemonList(SELECT_LIMIT, initialSelectIdentifier); 58 | pokemonSelectOptions.set(initialSelectIdentifier, constructPokemonOptions(initialPokemon)); 59 | 60 | messageActionRows[1].components[0].options = pokemonSelectOptions.get(initialSelectIdentifier); 61 | 62 | // eslint-disable-next-line no-shadow 63 | const identifiersResolver = async ({ interaction, paginator }) => { 64 | if (interaction.componentType === 'BUTTON') { 65 | let { selectOptionsIdentifier } = paginator.currentIdentifiers; 66 | switch (interaction.component.label) { 67 | case 'start': 68 | selectOptionsIdentifier = paginator.initialIdentifiers.selectOptionsIdentifier; 69 | break; 70 | case `-${SELECT_LIMIT}`: 71 | selectOptionsIdentifier -= 1; 72 | break; 73 | case `+${SELECT_LIMIT}`: 74 | selectOptionsIdentifier += 1; 75 | break; 76 | case 'end': 77 | selectOptionsIdentifier = MAX_SELECTIONS - 1; 78 | break; 79 | } 80 | 81 | if (selectOptionsIdentifier < 0) { 82 | selectOptionsIdentifier = MAX_SELECTIONS + (selectOptionsIdentifier % MAX_SELECTIONS); 83 | } else if (selectOptionsIdentifier >= MAX_SELECTIONS) { 84 | selectOptionsIdentifier %= MAX_SELECTIONS; 85 | } 86 | 87 | if (!pokemonSelectOptions.has(selectOptionsIdentifier)) { 88 | const limit = selectOptionsIdentifier === MAX_SELECTIONS - 1 ? 1 : SELECT_LIMIT; 89 | const pokemon = await PokeAPI.getPokemonList(limit, selectOptionsIdentifier * SELECT_LIMIT); 90 | pokemonSelectOptions.set(selectOptionsIdentifier, constructPokemonOptions(pokemon)); 91 | } 92 | 93 | return { 94 | ...paginator.currentIdentifiers, 95 | selectOptionsIdentifier, 96 | }; 97 | } else if (interaction.componentType === 'SELECT_MENU') { 98 | return { 99 | ...paginator.currentIdentifiers, 100 | pageIdentifier: interaction.values[0], 101 | }; 102 | } 103 | return null; 104 | }; 105 | 106 | const pageEmbedResolver = async ({ newIdentifiers, currentIdentifiers, paginator }) => { 107 | const { pageIdentifier: newPageIdentifier } = newIdentifiers; 108 | const { pageIdentifier: currentPageIdentifier } = currentIdentifiers; 109 | if (newPageIdentifier !== currentPageIdentifier) { 110 | // Pokemon name 111 | const pokemonResult = await PokeAPI.getPokemon(newPageIdentifier); 112 | const newEmbed = new MessageEmbed() 113 | .setTitle(`Pokedex #${`${pokemonResult.id}`.padStart(3, '0')} - ${newPageIdentifier}`) 114 | .setDescription(`Viewing ${newPageIdentifier}`) 115 | .setThumbnail(pokemonResult.sprites.front_default) 116 | .addField('Types', pokemonResult.types.map(typeObject => typeObject.type.name).join(', '), true) 117 | .addField( 118 | 'Abilities', 119 | pokemonResult.abilities.map(abilityObject => abilityObject.ability.name).join(', '), 120 | false, 121 | ); 122 | pokemonResult.stats.forEach(statObject => { 123 | newEmbed.addField(statObject.stat.name, `${statObject.base_stat}`, true); 124 | }); 125 | return newEmbed; 126 | } 127 | return paginator.currentPage; 128 | }; 129 | 130 | const handleBeforePageChanged = ({ newIdentifiers, currentIdentifiers, paginator }) => { 131 | const { selectOptionsIdentifier: currentSelectOptionsIdentifier } = currentIdentifiers; 132 | const { selectOptionsIdentifier: newSelectOptionsIdentifier } = newIdentifiers; 133 | if (currentSelectOptionsIdentifier !== newSelectOptionsIdentifier) { 134 | paginator.getComponent(1, 0).options = pokemonSelectOptions.get(newSelectOptionsIdentifier); 135 | if (newSelectOptionsIdentifier === MAX_SELECTIONS - 1) { 136 | paginator.getComponent(1, 0).placeholder = `Currently viewing #151 - #151`; 137 | } else { 138 | paginator.getComponent(1, 0).placeholder = `Currently viewing #${`${ 139 | newSelectOptionsIdentifier * SELECT_LIMIT + 1 140 | }`.padStart(3, '0')} - #${`${newSelectOptionsIdentifier * SELECT_LIMIT + SELECT_LIMIT}`.padStart(3, '0')}`; 141 | } 142 | } 143 | }; 144 | 145 | const shouldChangePage = ({ newIdentifiers, currentIdentifiers }) => { 146 | const { pageIdentifier: newPageIdentifier, selectOptionsIdentifier: newSelectOptionsIdentifier } = newIdentifiers; 147 | const { pageIdentifier: currentPageIdentifier, selectOptionsIdentifier: currentSelectOptionsIdentifier } = 148 | currentIdentifiers; 149 | 150 | return ( 151 | newPageIdentifier !== currentPageIdentifier || newSelectOptionsIdentifier !== currentSelectOptionsIdentifier 152 | ); 153 | }; 154 | 155 | const endHandler = ({ reason, paginator }) => { 156 | basicEndHandler({ reason, paginator }); 157 | pokemonSelectOptions.clear(); 158 | }; 159 | 160 | const actionRowPaginator = new ActionRowPaginator(interaction, { 161 | useCache: false, 162 | messageActionRows, 163 | initialIdentifiers: { 164 | pageIdentifier: 'bulbasaur', 165 | selectOptionsIdentifier: initialSelectIdentifier, 166 | }, 167 | identifiersResolver, 168 | pageEmbedResolver, 169 | shouldChangePage, 170 | }) 171 | .on(PaginatorEvents.COLLECT_ERROR, basicErrorHandler) 172 | .on(PaginatorEvents.BEFORE_PAGE_CHANGED, handleBeforePageChanged) 173 | .on(PaginatorEvents.PAGINATION_END, endHandler); 174 | await actionRowPaginator.send(); 175 | return actionRowPaginator.message; 176 | }, 177 | }; 178 | -------------------------------------------------------------------------------- /src/structures/BasePaginator.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const EventEmitter = require('events'); 4 | const { Collection } = require('discord.js'); 5 | const PaginatorEvents = require('../util/PaginatorEvents'); 6 | 7 | class BasePaginator extends EventEmitter { 8 | constructor(interaction, options) { 9 | super(); 10 | 11 | if (typeof interaction === 'undefined') { 12 | throw new Error('The received interaction is undefined'); 13 | } 14 | if (typeof interaction.channel === 'undefined') { 15 | throw new Error('The received interaction does not have a valid channel'); 16 | } 17 | if (typeof options.messageSender !== 'function') { 18 | throw new Error('messageSender must be a function'); 19 | } 20 | if (typeof options.pageEmbedResolver === 'undefined') { 21 | if (typeof options.useCache === 'boolean' && !options.useCache) { 22 | throw new Error('pageEmbedResolver must be provided if useCache is false'); 23 | } 24 | if (typeof options.pages === 'undefined' || options.pages.length === 0) { 25 | throw new Error('pages must be provided if not using a pageEmbedResolver'); 26 | } 27 | } 28 | 29 | Object.defineProperty(this, 'client', { value: interaction.client }); 30 | Object.defineProperty(this, 'user', { value: interaction.user }); 31 | Object.defineProperty(this, 'channel', { value: interaction.channel }); 32 | Object.defineProperty(this, 'interaction', { value: interaction }); 33 | Object.defineProperty(this, 'pages', { value: new Collection() }); 34 | 35 | this.options = options; 36 | this.messageSender = options.messageSender; 37 | this.collectorOptions = options.collectorOptions; 38 | this.identifiersResolver = options.identifiersResolver; 39 | this.pageEmbedResolver = options.pageEmbedResolver; 40 | this.messageOptionsResolver = options.messageOptionsResolver; 41 | this.shouldChangePage = options.shouldChangePage ?? null; 42 | this.footerResolver = options.footerResolver ?? null; 43 | this.initialIdentifiers = options.initialIdentifiers; 44 | this.currentIdentifiers = {}; 45 | this.useCache = typeof options.useCache === 'boolean' ? options.useCache : true; 46 | this.maxPageCache = options.maxPageCache; 47 | // If using cache and no embed resolver, pages can infer max number of pages. 48 | if (this.useCache && typeof this.pageEmbedResolver !== 'function') { 49 | this.maxNumberOfPages = this.options.pages.length; 50 | } else { 51 | this.maxNumberOfPages = options.maxNumberOfPages; 52 | } 53 | 54 | if (this.useCache && options.pages && options.pages.length > 0) { 55 | const { pages } = options; 56 | pages.forEach((page, pageIndex) => { 57 | this.pages.set(pageIndex, page); 58 | }); 59 | } 60 | } 61 | 62 | _createCollector() { 63 | throw new Error('_createCollector has not been implemented'); 64 | } 65 | 66 | getCollectorArgs(args) { 67 | return { ...args, paginator: this }; 68 | } 69 | 70 | _collectorFilter(...args) { 71 | return this._collectorOptions.filter(this.getCollectorArgs(args)); 72 | } 73 | 74 | _handleCollectEnd(collected, reason) { 75 | this.emit(PaginatorEvents.PAGINATION_END, { collected, reason, paginator: this }); 76 | this.removeAllListeners(); 77 | } 78 | 79 | async _resolvePageEmbed(changePageArgs) { 80 | const { 81 | newIdentifiers: { pageIdentifier }, 82 | } = changePageArgs; 83 | let newPage = await this.pageEmbedResolver(changePageArgs); 84 | if (this.useCache) { 85 | if (this.pages.size >= this.maxPageCache) { 86 | this.pages.clear(); 87 | } 88 | this.pages.set(pageIdentifier, newPage); 89 | } 90 | return newPage; 91 | } 92 | 93 | async _resolveMessageOptions({ messageOptions = {}, changePageArgs }) { 94 | if (typeof this.messageOptionsResolver === 'function') { 95 | const customMessageOptions = await this.messageOptionsResolver(changePageArgs); 96 | return { ...messageOptions, ...customMessageOptions, embeds: [this.currentPage] }; 97 | } 98 | return { ...messageOptions, embeds: [this.currentPage] }; 99 | } 100 | 101 | _postSetup() { 102 | this.emit(PaginatorEvents.PAGINATION_READY, this); 103 | } 104 | 105 | _shouldChangePage(changePageArgs) { 106 | if (this.shouldChangePage) return this.shouldChangePage(changePageArgs); 107 | return true; 108 | } 109 | 110 | _collectStart(args) { 111 | this.emit(PaginatorEvents.COLLECT_START, args); 112 | } 113 | 114 | _collectEnd(args) { 115 | this.emit(PaginatorEvents.COLLECT_END, args); 116 | } 117 | 118 | async _resolveCurrentPage(changePageArgs) { 119 | const { newIdentifiers, currentIdentifiers } = changePageArgs; 120 | if (this.useCache && this.pages.has(newIdentifiers.pageIdentifier)) { 121 | this.currentPage = this.pages.get(newIdentifiers.pageIdentifier); 122 | } else { 123 | this.currentPage = await this._resolvePageEmbed(changePageArgs); 124 | } 125 | if (typeof this.footerResolver === 'function') { 126 | this.currentPage.setFooter(await this.footerResolver(this)); 127 | } 128 | this.previousIdentifiers = currentIdentifiers; 129 | this.currentIdentifiers = newIdentifiers; 130 | } 131 | 132 | async send() { 133 | if (this._isSent) return; 134 | const changePageArgs = { 135 | newIdentifiers: this.initialIdentifiers, 136 | currentIdentifiers: {}, 137 | paginator: this, 138 | }; 139 | 140 | await this._resolveCurrentPage(changePageArgs); 141 | 142 | const messageOptions = await this._resolveMessageOptions({ changePageArgs }); 143 | this.currentPageMessageOptions = messageOptions; 144 | this.message = await this.messageSender({ interaction: this.interaction, messageOptions, paginator: this }); 145 | Object.defineProperty(this, '_isSent', { value: true }); 146 | this.collector = this._createCollector(); 147 | 148 | this.collector.on('collect', this._handleCollect.bind(this)); 149 | this.collector.on('end', this._handleCollectEnd.bind(this)); 150 | await this._postSetup(); 151 | } 152 | 153 | async _handleCollect(...args) { 154 | // This try / catch is to handle the edge case where a collect event is fired after a message delete call 155 | // but before the delete is complete, handling is offloaded to the user via collect error event 156 | try { 157 | const collectorArgs = this.getCollectorArgs(args); 158 | await this._collectStart(collectorArgs); 159 | const newIdentifiers = await this.identifiersResolver(collectorArgs); 160 | const changePageArgs = { 161 | collectorArgs, 162 | previousIdentifiers: this.previousIdentifiers, 163 | currentIdentifiers: this.currentIdentifiers, 164 | newIdentifiers, 165 | paginator: this, 166 | }; 167 | // Guard against a message deletion in the page resolver. 168 | if (this.message.deletable) { 169 | await this.changePage(changePageArgs); 170 | await this._collectEnd(collectorArgs); 171 | } 172 | } catch (error) { 173 | this.emit(PaginatorEvents.COLLECT_ERROR, { error, paginator: this }); 174 | } 175 | } 176 | 177 | async changePage(changePageArgs) { 178 | if (await this._shouldChangePage(changePageArgs)) { 179 | await this._resolveCurrentPage(changePageArgs); 180 | this.emit(PaginatorEvents.BEFORE_PAGE_CHANGED, changePageArgs); 181 | const messageOptions = await this._resolveMessageOptions({ changePageArgs }); 182 | this.currentPageMessageOptions = messageOptions; 183 | await this.editMessage({ changePageArgs, messageOptions }); 184 | this.emit(PaginatorEvents.PAGE_CHANGED, changePageArgs); 185 | } else { 186 | this.emit(PaginatorEvents.PAGE_UNCHANGED, changePageArgs); 187 | } 188 | } 189 | 190 | editMessage({ messageOptions }) { 191 | return this.message.edit(messageOptions); 192 | } 193 | 194 | get notSent() { 195 | return typeof this._isSent !== 'boolean' || !this._isSent; 196 | } 197 | 198 | get numberOfKnownPages() { 199 | if (this.useCache) return this.pages.size; 200 | else return this.maxNumberOfPages; 201 | } 202 | 203 | get collectorOptions() { 204 | return { 205 | ...this._collectorOptions, 206 | filter: this._collectorFilter.bind(this), 207 | }; 208 | } 209 | 210 | set collectorOptions(options) { 211 | this._collectorOptions = options; 212 | } 213 | 214 | setPage(pageIdentifier, pageEmbed) { 215 | this.pages.set(pageIdentifier, pageEmbed); 216 | return this; 217 | } 218 | 219 | setMessageSender(messageSender) { 220 | if (this.notSent) this.messageSender = messageSender; 221 | return this; 222 | } 223 | 224 | setCollectorFilter(collectorFilter) { 225 | this.options.filter = collectorFilter; 226 | return this; 227 | } 228 | 229 | setCollectorOptions(collectorOptions) { 230 | this.collectorOptions = collectorOptions; 231 | return this; 232 | } 233 | 234 | setPageIdentifierResolver(pageIdentifierResolver) { 235 | this.pageIdentifierResolver = pageIdentifierResolver; 236 | return this; 237 | } 238 | 239 | setPageEmbedResolver(pageEmbedResolver) { 240 | this.pageEmbedResolver = pageEmbedResolver; 241 | return this; 242 | } 243 | 244 | setFooterResolver(footerResolver) { 245 | this.footerResolver = footerResolver; 246 | return this; 247 | } 248 | 249 | setInitialIdentifiers(initialIdentifiers) { 250 | if (this.notSent) this.initialIdentifiers = initialIdentifiers; 251 | return this; 252 | } 253 | 254 | stop(reason = 'paginator') { 255 | if (!this.collector.ended) this.collector.stop(reason); 256 | this.removeAllListeners(); 257 | } 258 | } 259 | 260 | module.exports = BasePaginator; 261 | -------------------------------------------------------------------------------- /example/src/commands/AdvancedPagination.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { SlashCommandBuilder } = require('@discordjs/builders'); 4 | const { Collection, MessageEmbed } = require('discord.js'); 5 | const { PaginatorEvents, ActionRowPaginator } = require('../../../src'); 6 | const { basicEndHandler, basicErrorHandler } = require('../util/Constants'); 7 | const { constructPokemonOptions, PokeAPI } = require('../util/PokeAPI'); 8 | 9 | module.exports = { 10 | data: new SlashCommandBuilder() 11 | .setName('advanced-pagination') 12 | .setDescription('Replies with a dynamic advanced pagination'), 13 | async execute(interaction) { 14 | const SELECT_LIMIT = 25; 15 | const INITIAL_SELECT_IDENTIFIER = 0; 16 | const INITIAL_TYPE_IDENTIFIER = 'all'; 17 | 18 | const pokemonTypesResponse = await PokeAPI.getTypes(); 19 | const pokemonTypeSelectOptions = [{ label: INITIAL_TYPE_IDENTIFIER, value: INITIAL_TYPE_IDENTIFIER }]; 20 | const allPokemonRequest = await PokeAPI.getAllPokemon(); 21 | 22 | let totalPokemonOfType = allPokemonRequest.count; 23 | let maxPokemonSelections = Math.floor(totalPokemonOfType / SELECT_LIMIT); 24 | if (totalPokemonOfType % SELECT_LIMIT > 0) maxPokemonSelections += 1; 25 | 26 | pokemonTypesResponse.forEach(type => { 27 | if (type.name !== 'unknown') { 28 | pokemonTypeSelectOptions.push({ 29 | label: type.name, 30 | value: type.name, 31 | }); 32 | } 33 | }); 34 | 35 | const pokemonSelectOptions = new Collection(); 36 | const initialPokemon = await PokeAPI.getPokemonList(SELECT_LIMIT, INITIAL_SELECT_IDENTIFIER); 37 | pokemonSelectOptions.set(INITIAL_SELECT_IDENTIFIER, constructPokemonOptions(initialPokemon)); 38 | 39 | const messageActionRows = [ 40 | { 41 | components: [ 42 | { 43 | type: 'SELECT_MENU', 44 | placeholder: 'Select a type to filter the Pokemon', 45 | options: pokemonTypeSelectOptions, 46 | customId: 'pokemon-type', 47 | minItems: 1, 48 | maxItems: 1, 49 | }, 50 | ], 51 | }, 52 | { 53 | components: [ 54 | { 55 | type: 'SELECT_MENU', 56 | placeholder: `Currently viewing #001 - #025 of ${totalPokemonOfType}`, 57 | options: pokemonSelectOptions.get(INITIAL_SELECT_IDENTIFIER), 58 | customId: 'pokemon-select', 59 | minItems: 1, 60 | maxItems: 1, 61 | }, 62 | ], 63 | }, 64 | { 65 | components: [ 66 | { 67 | type: 'BUTTON', 68 | emoji: '⏪', 69 | label: 'start', 70 | style: 'SECONDARY', 71 | }, 72 | { 73 | type: 'BUTTON', 74 | label: `-${SELECT_LIMIT}`, 75 | style: 'PRIMARY', 76 | }, 77 | { 78 | type: 'BUTTON', 79 | label: `+${SELECT_LIMIT}`, 80 | style: 'PRIMARY', 81 | }, 82 | { 83 | type: 'BUTTON', 84 | emoji: '⏩', 85 | label: 'end', 86 | style: 'SECONDARY', 87 | }, 88 | ], 89 | }, 90 | ]; 91 | 92 | const handleButtonIdentifier = async (label, paginator) => { 93 | // Handles a button based navigation, this is considered an "action" which will be used 94 | // to change / update the existing selectOptionsIdentifier. 95 | let { pokemonTypeIdentifier, selectOptionsIdentifier } = paginator.currentIdentifiers; 96 | switch (label) { 97 | case 'start': 98 | selectOptionsIdentifier = INITIAL_SELECT_IDENTIFIER; 99 | break; 100 | case `-${SELECT_LIMIT}`: 101 | selectOptionsIdentifier -= 1; 102 | break; 103 | case `+${SELECT_LIMIT}`: 104 | selectOptionsIdentifier += 1; 105 | break; 106 | case 'end': 107 | selectOptionsIdentifier = maxPokemonSelections - 1; 108 | break; 109 | } 110 | 111 | // If the select identifier becomes out of bounds, make it cyclic 112 | if (selectOptionsIdentifier < INITIAL_SELECT_IDENTIFIER) { 113 | selectOptionsIdentifier = maxPokemonSelections + (selectOptionsIdentifier % maxPokemonSelections); 114 | } else if (selectOptionsIdentifier >= maxPokemonSelections) { 115 | selectOptionsIdentifier %= maxPokemonSelections; 116 | } 117 | // Determine whether we are requesting based on type or pokemon name. 118 | const isAll = pokemonTypeIdentifier === INITIAL_TYPE_IDENTIFIER; 119 | // If the select options aren't cached, fetch and cache them. 120 | if (!pokemonSelectOptions.has(selectOptionsIdentifier)) { 121 | const offset = selectOptionsIdentifier * SELECT_LIMIT; 122 | const limit = 123 | selectOptionsIdentifier === maxPokemonSelections - 1 ? totalPokemonOfType % SELECT_LIMIT : SELECT_LIMIT; 124 | try { 125 | const pokemonList = isAll 126 | ? await PokeAPI.getPokemonList(limit, offset) 127 | : await PokeAPI.getPokemonListOfType(pokemonTypeIdentifier, offset, offset + limit); 128 | console.log(`COnstructiong new options based on start/end: ${offset}/${offset + limit}`); 129 | pokemonSelectOptions.set(selectOptionsIdentifier, constructPokemonOptions(pokemonList)); 130 | } catch (error) { 131 | console.log(`ERROR FETCHING POKEMON LIST:\n${error}`); 132 | return {}; 133 | } 134 | } 135 | return { selectOptionsIdentifier }; 136 | }; 137 | 138 | const handlePokemonTypeChange = async pokemonType => { 139 | // Handles when a type option or 'all' is selected. 140 | console.log(`Handling type change to '${pokemonType}'`); 141 | // Currently the select options are only cached per-type. 142 | // Could cache them all with collections mapped to a type. 143 | pokemonSelectOptions.clear(); 144 | const isAll = pokemonType === INITIAL_TYPE_IDENTIFIER; 145 | try { 146 | const pokemonResponse = isAll ? await PokeAPI.getAllPokemon() : await PokeAPI.getPokemonOfType(pokemonType); 147 | const pokemonList = isAll ? pokemonResponse.results : pokemonResponse.pokemon; 148 | if (isAll) { 149 | totalPokemonOfType = pokemonResponse.count; 150 | } else { 151 | totalPokemonOfType = pokemonResponse.pokemon.length; 152 | } 153 | console.log(`Total pokemon of type '${pokemonType}': ${totalPokemonOfType}`); 154 | maxPokemonSelections = Math.floor(totalPokemonOfType / SELECT_LIMIT); 155 | if (totalPokemonOfType % SELECT_LIMIT > 0) maxPokemonSelections += 1; 156 | pokemonSelectOptions.set( 157 | INITIAL_SELECT_IDENTIFIER, 158 | constructPokemonOptions( 159 | isAll 160 | ? pokemonList 161 | : pokemonList.slice(INITIAL_SELECT_IDENTIFIER, SELECT_LIMIT).map(pokemonEntry => pokemonEntry.pokemon), 162 | ), 163 | ); 164 | return { selectOptionsIdentifier: INITIAL_SELECT_IDENTIFIER, pokemonTypeIdentifier: pokemonType }; 165 | } catch (error) { 166 | console.log(`Error in handlePokemonTypeChange\n${error}`); 167 | return {}; 168 | } 169 | }; 170 | 171 | // eslint-disable-next-line no-shadow 172 | const identifiersResolver = async ({ interaction, paginator }) => { 173 | let newIdentifiers = {}; 174 | if (interaction.componentType === 'BUTTON') { 175 | newIdentifiers = await handleButtonIdentifier(interaction.component.label, paginator); 176 | } else if (interaction.componentType === 'SELECT_MENU') { 177 | if (interaction.component.customId.includes('pokemon-type')) { 178 | const { pokemonTypeIdentifier: currentPokemonType } = paginator.currentIdentifiers; 179 | const newPokemonType = interaction.values[0]; 180 | if (newPokemonType !== currentPokemonType) { 181 | newIdentifiers = await handlePokemonTypeChange(newPokemonType, paginator); 182 | } 183 | } else if (interaction.component.customId.includes('pokemon-select')) { 184 | newIdentifiers = { 185 | pageIdentifier: interaction.values[0], 186 | }; 187 | } 188 | } 189 | return { ...paginator.currentIdentifiers, ...newIdentifiers }; 190 | }; 191 | 192 | const pageEmbedResolver = async ({ newIdentifiers, currentIdentifiers, paginator }) => { 193 | const { pageIdentifier: newPageIdentifier } = newIdentifiers; 194 | const { pageIdentifier: currentPageIdentifier } = currentIdentifiers; 195 | if (newPageIdentifier !== currentPageIdentifier) { 196 | try { 197 | // Pokemon name 198 | const pokemonResult = await PokeAPI.getPokemon(newPageIdentifier); 199 | const newEmbed = new MessageEmbed() 200 | .setTitle(`Pokedex #${`${pokemonResult.id}`.padStart(3, '0')} - ${newPageIdentifier}`) 201 | .setDescription(`Viewing ${newPageIdentifier}`) 202 | .setThumbnail(pokemonResult.sprites.front_default) 203 | .setFooter('Information fetched from https://pokeapi.co/') 204 | .addField('Types', pokemonResult.types.map(typeObject => typeObject.type.name).join(', '), true) 205 | .addField( 206 | 'Abilities', 207 | pokemonResult.abilities.map(abilityObject => abilityObject.ability.name).join(', '), 208 | false, 209 | ); 210 | pokemonResult.stats.forEach(statObject => { 211 | newEmbed.addField(statObject.stat.name, `${statObject.base_stat}`, true); 212 | }); 213 | return newEmbed; 214 | } catch (error) { 215 | console.log(`Error in pageEmbedResolver\n${error}`); 216 | } 217 | } 218 | return paginator.currentPage; 219 | }; 220 | 221 | const handleBeforePageChanged = ({ newIdentifiers, currentIdentifiers, paginator }) => { 222 | const { 223 | selectOptionsIdentifier: currentSelectOptionsIdentifier, 224 | pokemonTypeIdentifier: currentPokemonTypeIdentifier, 225 | } = currentIdentifiers; 226 | const { selectOptionsIdentifier: newSelectOptionsIdentifier, pokemonTypeIdentifier: newPokemonTypeIdentifier } = 227 | newIdentifiers; 228 | 229 | if (newPokemonTypeIdentifier !== currentPokemonTypeIdentifier) { 230 | paginator.getComponent(0, 0).placeholder = 231 | newPokemonTypeIdentifier === INITIAL_TYPE_IDENTIFIER 232 | ? 'Select a type to filter the Pokemon' 233 | : `Currently filtered on the '${newPokemonTypeIdentifier}' type`; 234 | } 235 | 236 | if ( 237 | newSelectOptionsIdentifier !== currentSelectOptionsIdentifier || 238 | newPokemonTypeIdentifier !== currentPokemonTypeIdentifier 239 | ) { 240 | paginator.getComponent(1, 0).options = pokemonSelectOptions.get(newSelectOptionsIdentifier); 241 | const endOffset = newSelectOptionsIdentifier * SELECT_LIMIT + SELECT_LIMIT; 242 | paginator.getComponent(1, 0).placeholder = `Currently viewing #${`${ 243 | newSelectOptionsIdentifier * SELECT_LIMIT + 1 244 | }`.padStart(3, '0')} - #${`${endOffset >= totalPokemonOfType ? totalPokemonOfType : endOffset} `.padStart( 245 | 3, 246 | '0', 247 | )} of #${totalPokemonOfType}`; 248 | } 249 | }; 250 | 251 | const shouldChangePage = ({ newIdentifiers, currentIdentifiers }) => { 252 | const { 253 | pageIdentifier: newPageIdentifier, 254 | selectOptionsIdentifier: newSelectOptionsIdentifier, 255 | pokemonTypeIdentifier: newPokemonTypeIdentifier, 256 | } = newIdentifiers; 257 | const { 258 | pageIdentifier: currentPageIdentifier, 259 | selectOptionsIdentifier: currentSelectOptionsIdentifier, 260 | pokemonTypeIdentifier: currentPokemonTypeIdentifier, 261 | } = currentIdentifiers; 262 | 263 | return ( 264 | newPageIdentifier !== currentPageIdentifier || 265 | newSelectOptionsIdentifier !== currentSelectOptionsIdentifier || 266 | newPokemonTypeIdentifier !== currentPokemonTypeIdentifier 267 | ); 268 | }; 269 | 270 | const endHandler = ({ reason, paginator }) => { 271 | basicEndHandler({ reason, paginator }); 272 | pokemonSelectOptions.clear(); 273 | }; 274 | 275 | const actionRowPaginator = new ActionRowPaginator(interaction, { 276 | useCache: false, 277 | messageActionRows, 278 | initialIdentifiers: { 279 | pokemonTypeIdentifier: INITIAL_TYPE_IDENTIFIER, 280 | pageIdentifier: 'bulbasaur', 281 | selectOptionsIdentifier: INITIAL_SELECT_IDENTIFIER, 282 | }, 283 | identifiersResolver, 284 | pageEmbedResolver, 285 | shouldChangePage, 286 | }) 287 | .on(PaginatorEvents.COLLECT_ERROR, basicErrorHandler) 288 | .on(PaginatorEvents.BEFORE_PAGE_CHANGED, handleBeforePageChanged) 289 | .on(PaginatorEvents.PAGINATION_END, endHandler); 290 | await actionRowPaginator.send(); 291 | return actionRowPaginator.message; 292 | }, 293 | }; 294 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

3 | NPM info 5 |

6 |
7 | 8 | # discord.js-pagination 9 | A simple utility (or advanced - it's your choice) to paginate discord embeds. Built on discord.js@^13.0.0. 10 | 11 | To see how the example paginations look, checkout the [example bot](example/README.md) (the readme has gifs)! 12 | 13 | ### Key Features 14 | - Custom emoji reactions with the ReactionPaginator (see the example app). 15 | - Custom button pagination with the ButtonPaginator (see the example app). 16 | - Custom select menu pagination with the SelectPaginator (see the example app). 17 | - If you have a global interaction listener you can ignore paginator interactions by checking: `interaction.customId.startsWith("paginator")` - this prefix is customizable, set it on all of your paginators. 18 | - Multiple Action Row (combined select and button) pagination! 19 | 20 | ### Table of Contents 21 | - [Installation](#installation) 22 | - [Updating v3 to v4](#updating) 23 | - [Basic Usage](#basic-usage) 24 | - [Types](#types) 25 | - [Paginator Properties](#paginator-properties) 26 | - [Paginator Options](#paginator-options) 27 | - [Paginator Events](#paginator-events) 28 | # Installation 29 | 30 | * `npm install @psibean/discord.js-pagination` 31 | 32 | # Updating 33 | 34 | To update from version 3 to version 4 you may want to take a look at the [difference between the two versions](https://github.com/psibean/discord.js-pagination/compare/d08bf8c25a8f6284354acfb2a9b8243a218c984e..55364f3d653df0ddf6967c99f5c08ffc9d3a08d5) in the example app. 35 | 36 | However, for SIMPLE cases, you likely just need to move your pages parameter into the options object in the constructor call, see the [basic usage](#basic-usage) below! 37 | 38 | If you want to make use of the dynamic embed creation you'll want to learn how to use the `identifiersResolver` and the `pageEmbedResolver`, read their descriptions below and check the example app for further demonstration. 39 | 40 | # Basic Usage 41 | 42 | For all paginators, the default `messageSender` will call `interaction#editReply` then return `interaction#fetchReply`, this means your command (as per the example bot) must call `interaction#deferReply`. 43 | 44 | You should use `interaction#deferReply` when you receive an interaction that will send an embed. The paginators default `messageSender` uses `interaction#editReply`. If you do not wish to do this you'll need to pass in your own `messageSender`. 45 | 46 | For the below examples, the pages can be constructed as per the [example bot](example/README.md): 47 | 48 | ```js 49 | const pages = []; 50 | for (let i = 0; i 10; i++) { 51 | const pageEmbed = new MessageEmbed(); 52 | pageEmbed 53 | .setTitle(`This embed is index ${i}!`) 54 | .setDescription(`That means it is page #${i + 1}`); 55 | pages.push(pageEmbed); 56 | } 57 | ``` 58 | 59 | You will of course want to construct your own pages. 60 | 61 | **For more complex usage see the [example bot](example/README.md).** 62 | 63 | ## ReactionPaginator 64 | 65 | This allows pages to be navigated by reacting to a message. 66 | 67 | ```js 68 | const { PaginatorEvents, ReactionPaginator } = require('@psibean/discord.js-pagination'); 69 | 70 | const reactionPaginator = new ReactionPaginator(interaction, { pages }) 71 | .on(PaginatorEvents.COLLECT_ERROR, ({ error }) => console.log(error)); 72 | await reactionPaginator.send(); 73 | ``` 74 | 75 | ## ButtonPaginator 76 | 77 | This allows pages to be navigated by clicking buttons. 78 | 79 | ```js 80 | const { ButtonPaginator } = require('@psibean/discord.js-pagination'); 81 | 82 | const buttonPaginator = new ButtonPaginator(interaction, { pages }); 83 | await buttonPaginator.send(); 84 | ``` 85 | 86 | ## SelectPaginator 87 | 88 | This allows pages to be navigated using a select menu. 89 | 90 | For the select pagination embed you'll need to supply an array of [MessageSelectOptions](https://discord.js.org/#/docs/main/stable/typedef/MessageSelectOption) to the pagination options via `selectOptions`. By default the pagination will map the value of the provided options to your pages index based on their ordering. Then the page change will be determined by the selected value and this mapping. If you want to map your select options and pages differently you can provide the `pagesMap` ({ selectOptions, paginator }) function which should return a dictionary. You'll then want to provide a custom `pageIndexResolver`. 91 | 92 | ```js 93 | const { SelectPaginator } = require('@psibean/discord.js-pagination'); 94 | 95 | // The default pagesMap will map these option values to the index 96 | const selectOptions = []; 97 | for (let i = 0; i < 10; i++) 98 | selectOptions.push({ 99 | label: `"Page #${i + 1}`, 100 | value: `${i}`, 101 | description: `This will take you to page #${i + 1}` 102 | }); 103 | 104 | const selectPaginator = new SelectPaginator(interaction, pages); 105 | await selectPaginator.send(); 106 | ``` 107 | 108 | # Types 109 | 110 | ## PaginatorIdentifiers 111 | 112 | This object is returned by the identifiersResolver and can contain any number of identifiers but at bare minimum it must contain a pageIdentifier. 113 | 114 | { ...args, pageIdentifier: string | number } 115 | - **args**: Any additional identifiers you wish to use. 116 | - **pageIdentifier**: Used as a unique identifier to resolve a page 117 | 118 | ## PaginatorCollectorArgs 119 | 120 | { ...args, paginator } 121 | - **args**: All arguments provided by the collect listener. 122 | - **paginator** : This is a reference to the paginator instance. 123 | 124 | For the ReactionCollector this is: 125 | ``` 126 | { reaction, user, paginator } 127 | ``` 128 | 129 | For ActionRowPaginator, ButtonPaginator and SelectPaginator this is: 130 | ``` 131 | { interaction, paginator } 132 | ``` 133 | 134 | ## ChangePageArgs 135 | 136 | { collectorArgs, previousIdentifiers, currentIdentifiers, newIdentifiers, paginator } 137 | - **collectorArgs**: The [PaginatorCollectorArgs](#paginatorcollectorargs) - only in the collect event (not initial send). 138 | - **previousIdentifiers**: The [PaginatorIdentifiers](#paginatoridentifiers) of the previous page. Empty object if no navigation. 139 | - **currentIdentifiers**: The [PaginatorIdentifiers](#paginatoridentifiers) of the current page, before changing. 140 | - **newIdentifiers**: THe [PaginatorIdentifiers](#paginatoridentifiers) of the page to be changed to. 141 | - **paginator** : This is a reference to the paginator instance. 142 | 143 | # Paginator Properties 144 | 145 | The paginator has a variety of properties you can acces -and should, especially if you're customising! 146 | 147 | Properties can be accessed via `paginator#propertyName` 148 | 149 | ## Base Properties 150 | 151 | These properties are common to all paginators. 152 | 153 | - **client** : The client that instantiated the paginator. 154 | - **interaction** : The interaction that initiated the instantiation of the paginator. 155 | - **user** : The user who sent the interaction that instantiated the paginator. 156 | - **channel** : The channel where the interaction came from, this is also where the paginator message will be sent. 157 | - **pages** : The cache of MessageEmbeds mapped by their respective `PaginationIdentifiers#pageIdentifier`. 158 | - If pages are provided in the options of the constructor, this will be all of the pages provided mapped by their index. 159 | - **initialIdentifiers** : The initial [PaginatorIdentifiers](#paginatoridentifiers) provided in the paginator options. 160 | - **numberOfPages** : The number of pages. 161 | - **previousPageIdentifiers** : The [PaginatorIdentifiers](#paginatoridentifiers) from the previous navigation, `null` indicates no page change yet. 162 | - **currentPageIdentifiers** : The [PaginatorIdentifiers](#paginatoridentifiers) of the current page. 163 | - **options** : The options provided to the paginator after merging defaults. 164 | 165 | ## ActionRowPaginator Properties 166 | 167 | These properties are common to the `ButtonPaginator` and `SelectPaginator`. 168 | 169 | - **customIdPrefix** : The `customIdPrefix` provided in options. Used to prefix all `MessageComponent#customId`'s. 170 | - **customIdSuffix** : The `interaction#id`. Used to suffix all `MessageComponent#customId`'s. 171 | 172 | ## SelectPaginator Properties 173 | 174 | These properties are specific to the `SelectPaginator`. 175 | 176 | - **selectMenu** : The select menu on the paginator message action row. 177 | 178 | # Paginator Options 179 | 180 | The following options are available for the respective paginators. 181 | 182 | ## Base Options 183 | 184 | All paginators can take the options of their respective collector, see discord.js documentation for those. Instead of filter, provide `collectorFilter`. The options listed here are available to all paginators. 185 | 186 | ### messageSender 187 | 188 | The function used to send the intial page, it returns the message. 189 | 190 | ({ interaction, messageOptions }): Promise<Message> | Message 191 | - **interaction** : This is a reference to the initial interaction that instantiated the paginator. 192 | - **messageOptions** : The MessageOptions for the initial page. 193 | 194 | Defaults to: 195 | ```js 196 | messageSender: async ({ interaction, messageOptions }) => { 197 | await paginator.interaction.editReply(messageOptions); 198 | return paginator.interaction.fetchReply(); 199 | } 200 | ``` 201 | ### pageEmbedResolver 202 | 203 | This is used to determine the current pages embed based on the change page args. 204 | 205 | (changePageArgs: [ChangePageArgs](#changepageargs)): Promise<MessageEmbed> | MessageEmbed 206 | 207 | If using cache this function will only be called if the embed isn't already in the cache and the embed will be added to the cache mapped to it's corresponding `pageIdentifier`. 208 | 209 | ### useCache 210 | 211 | Whether or not embeds should be cached, keyed by their pageIdentifier. 212 | 213 | Defaults to true. 214 | 215 | ### maxPageCache 216 | 217 | The maximum number of pages that should be cached. If this number is reached the cache will be emptied before a new page is cached. 218 | 219 | Defaults to 100. 220 | ### messageOptionsResolver 221 | 222 | This function is used to determine additional MessageOptions for the page being changed to. 223 | 224 | (options: [ChangePageArgs](#changepageargs)): Promise<MessageOptions> | MessageOptions 225 | 226 | Defaults to returning an empty object. Optional. 227 | 228 | ### collectorOptions 229 | 230 | This is the options that will be passed to the collector when it is created. 231 | 232 | For the ReactionPaginator, defaults to: 233 | 234 | ```js 235 | { 236 | idle: 6e4, 237 | filter: ({ reaction, user, paginator }) => 238 | user === paginator.user && paginator.emojiList.includes(reaction.emoji.name) && !user.bot, 239 | } 240 | ``` 241 | 242 | For the ActionRowPaginator, ButtonPaginator and SelectPaginator, defaults to: 243 | 244 | ```js 245 | { 246 | idle: 6e4, 247 | filter: ({ interaction, paginator }) => 248 | interaction.isMessageComponent() && 249 | interaction.component.customId.startsWith(paginator.customIdPrefix) && 250 | interaction.user === paginator.user && 251 | !interaction.user.bot, 252 | }, 253 | } 254 | ``` 255 | 256 | ### identifiersResolver 257 | 258 | This is the function used to determine the new identifiers object based on the interaction that occurred. The object **must** contain a `pageIdentifier` but can otherwise contain anything else. 259 | 260 | (collectorArgs: [PaginatorCollectorArgs](#paginatorcollectorargs)): Promise<[PaginatorIdentifiers](#paginatoridentifiers)> | [PaginatorIdentifiers](#paginatoridentifiers) 261 | 262 | Whatever this function returns will be used as the `newIdentifiers` object for it's collect event. 263 | 264 | For the ReactionPaginator, defaults to: 265 | 266 | ```js 267 | ({ reaction, paginator }) => { 268 | let { pageIdentifier } = paginator.currentIdentifiers; 269 | switch (reaction.emoji.name) { 270 | case paginator.emojiList[0]: 271 | pageIdentifier -= 1; 272 | break; 273 | case paginator.emojiList[1]: 274 | pageIdentifier += 1; 275 | break; 276 | } 277 | // The default identifier is a cyclic index. 278 | if (pageIdentifier < 0) { 279 | pageIdentifier = paginator.maxNumberOfPages + (pageIdentifier % paginator.maxNumberOfPages); 280 | } else if (pageIdentifier >= paginator.maxNumberOfPages) { 281 | pageIdentifier %= paginator.maxNumberOfPages; 282 | } 283 | return { ...paginator.currentIdentifiers, pageIdentifier }; 284 | } 285 | ``` 286 | 287 | For the ButtonPaginator, defaults to: 288 | 289 | ```js 290 | ({ interaction, paginator }) => { 291 | const val = interaction.component.label.toLowerCase(); 292 | let { pageIdentifier } = paginator.currentIdentifiers; 293 | if (val === 'previous') pageIdentifier -= 1; 294 | else if (val === 'next') pageIdentifier += 1; 295 | // The default identifier is a cyclic index. 296 | if (pageIdentifier < 0) { 297 | pageIdentifier = paginator.maxNumberOfPages + (pageIdentifier % paginator.maxNumberOfPages); 298 | } else if (pageIdentifier >= paginator.maxNumberOfPages) { 299 | pageIdentifier %= paginator.maxNumberOfPages; 300 | } 301 | return { ...paginator.currentIdentifiers, pageIdentifier }; 302 | } 303 | ``` 304 | 305 | For the SelectPaginator, defaults to: 306 | 307 | ```js 308 | ({ interaction, paginator }) => { 309 | const [selectedValue] = interaction.values; 310 | return { ...paginator.currentIdentifiers, pageIdentifier: parseInt(selectedValue) }; 311 | }, 312 | ``` 313 | 314 | ### shouldChangePage 315 | 316 | This is the function used to determine whether or not a page change should occur during a collect event. If not provided, a page change will always occur during a collect event. 317 | 318 | (args: [ChangePageArgs](#changepageargs)): Promise<boolean> | boolean 319 | 320 | Defaults to: 321 | 322 | ```js 323 | ({ newIdentifiers, currentIdentifiers, paginator }) => 324 | paginator.message.deletable && newIdentifiers.pageIdentifier !== currentIdentifiers.pageIdentifier, 325 | ``` 326 | 327 | ### footerResolver 328 | 329 | This is the function used to set the footer of each page. If not provided the embeds will use whatever footers were originally set on them. 330 | 331 | (paginator): Promise<string> | string 332 | - **paginator** : This is a reference to the paginator instance. 333 | 334 | Defaults to undefined. Optional. 335 | 336 | ### initialIdentifiers 337 | 338 | This is the initial [PageIdentifiers](#pageidentifiers) which is passed in as the `newIdentifiers` in the very first send. 339 | 340 | ## ReactionPaginator Options 341 | 342 | These options are specific to the `ReactionPaginator`. 343 | 344 | ### emojiList 345 | 346 | An array of emoji resolvables to use as reactions. 347 | 348 | Defaults to: ['⏪', '⏩'] 349 | 350 | ## ActionRowPaginator Options 351 | 352 | These options are common to the `ActionRowPaginator`, `ButtonPaginator`, and `SelectPaginator`. 353 | 354 | ### messageActionRows 355 | 356 | An array of messageActionRows (they will have their type set to ACTION_ROW). 357 | Any components provided up front will have their customId generated. 358 | 359 | Defaults to: 360 | ``` 361 | [ 362 | { 363 | type: 'ACTION_ROW', 364 | components: [], 365 | }, 366 | ], 367 | ``` 368 | 369 | ### customIdPrefix 370 | 371 | This is used to prefix all of the `MessageComponent#customId`'s. 372 | 373 | Defaults to: "paginator" 374 | 375 | ## ButtonPaginator Options 376 | 377 | These options are specific to the `ButtonPaginator`. 378 | 379 | ### buttons 380 | 381 | An array of [MessageButtonOptions](https://discord.js.org/#/docs/main/stable/typedef/MessageButtonOptions) which will be added to the paginators action row. 382 | Can be skipped if you're degining the `ActionRowPaginator#messageActionRows` option. Otherwise these will be added to either the first action row, or added based on a `row` property. 383 | 384 | Note: 385 | - customId is not required, the paginator will update the customId to be: `-[-]` 386 | where the suffix is the id of the received interaction that initiated the paginator. 387 | - type will be updated to `BUTTON` 388 | - style will default to `PRIMARY` if not set 389 | 390 | Defaults to: 391 | 392 | ```js 393 | [ 394 | { 395 | label: 'Previous', 396 | }, 397 | { 398 | label: 'Next', 399 | } 400 | ], 401 | ``` 402 | 403 | ## SelectPaginator Options 404 | 405 | These options are specific to the `SelectPaginator`. 406 | 407 | ### selectOptions 408 | 409 | An array of [MessageSelectOptions](https://discord.js.org/#/docs/main/stable/typedef/MessageSelectOption) to be added to the select menu. 410 | 411 | Note: 412 | - customId is not required, the paginator will update the customId to be: `-[-]` 413 | where the prefix is `paginator#customIdPrefix` and the suffix is the id of the received interaction that initiated the paginator. 414 | 415 | ### pagesMap 416 | 417 | A function that returns a dictionary, or a dictionary. The dictionary should provide a way to map the provided `selectOptions` to your provided `pages`. 418 | 419 | If a function: 420 | 421 | ({ selectOptions, paginator}): Dictionary<K, V> 422 | - **selectOptions** : The selectOptions provided to the paginator after having their customId updated. 423 | - **paginator** : This is a reference to the paginator instance. 424 | 425 | # Paginator Events 426 | 427 | Events can be imported by: 428 | 429 | ``` 430 | const { PaginatorEvents } = require('@psibean/discord.js-pagination'); 431 | ``` 432 | And accessed by `PaginatorEvents#EventName` 433 | 434 | To listen to an event: 435 | ``` 436 | paginator.on(PaginatorEvents#EventName, eventHandler); 437 | ``` 438 | 439 | All paginators have the following events (by EventName): 440 | 441 | ### BEFORE_PAGE_CHANGED 442 | 443 | 'beforePageChanged' 444 | 445 | This event is raised in the paginator collectors collect event before the message is edited with a new page. 446 | 447 | Parameters: ({ ...*, newPageIdentifier, currentPageIdentifier, paginator }) 448 | - **...\*** : Includes any args provided by the collect listener. 449 | - **newPageIdentifier** : This is the index of the page being changed to. 450 | - **currentPageIdentifier** : This is the index of the current page (before being changed). 451 | - **paginator** : This is a reference to the paginator instance. 452 | 453 | ### COLLECT_END 454 | 455 | 'collectEnd' 456 | 457 | This event is raised at the end of each collect handled by the paginator collector. Note that this won't be called if an error is thrown or if the paginator does not change page. Use the `COLLECT_ERROR` and `PAGE_CHANGED` events respectively in those cases. 458 | 459 | Parameters: ({ error, paginator }) 460 | - **error** : This is the error that was caught. 461 | - **paginator** : This is a reference to the paginator instance. 462 | 463 | ### COLLECT_ERROR 464 | 465 | 'collectError' 466 | 467 | This event is raised when an error occurs within the collect event of the paginators collector. 468 | 469 | Parameters: ({ error, paginator }) 470 | - **error** : This is the error that was caught. 471 | - **paginator** : This is a reference to the paginator instance. 472 | 473 | ### COLLECT_START 474 | 475 | 'collectStart' 476 | 477 | This event is raised when an error occurs within the collect event of the paginators collector. 478 | 479 | Parameters: ({ error, paginator }) 480 | - **error** : This is the error that was caught. 481 | - **paginator** : This is a reference to the paginator instance. 482 | 483 | ### PAGE_CHANGED 484 | 485 | 'pageChanged' 486 | 487 | This event is raised in the paginator collectors collect event after the message is edited with a new page 488 | 489 | Parameters: ({ ...*, newPageIdentifier, currentPageIdentifier, paginator }) 490 | - ...* : Includes any args provided by the collect listener. 491 | - **newPageIdentifier** : This is the index of the page being changed to. 492 | - **currentPageIdentifier** : This is the index of the current page (before being changed). 493 | - **paginator** : This is a reference to the paginator instance. 494 | 495 | **It should be noted:** `paginator#currentPageIdentifier`, `paginator#previousPageIdentifier` and `paginator#currentPage` will now reflect values based on the `newPageIdentifier`, which is not the case for BEFORE_PAGE_CHANGED. 496 | 497 | ### PAGE_UNCHANGED 498 | 499 | 'pageUnchanged' 500 | 501 | This event is raised in the paginator collectors collect event when `paginator#shouldChangePage` has returned false. For example if the new page index matches the current index, a collect event occurred but did not cause a page change. 502 | 503 | Parameters: ({ ...*, newPageIdentifier, currentPageIdentifier, paginator }) 504 | - ...* : Includes any args provided by the collect listener. 505 | - **newPageIdentifier** : This is the index of the page being changed to. 506 | - **currentPageIdentifier** : This is the index of the current page (before being changed). 507 | - **paginator** : This is a reference to the paginator instance. 508 | 509 | ### PAGINATION_END 510 | 511 | 'paginationEnd' 512 | 513 | This event is raised in the paginators collector end event. 514 | 515 | Parameters: ({ collected, reason, paginator }) 516 | - **collected** : The elements collected by the paginators collector. 517 | - **reason** : The reason the paginators collector ended. 518 | - **paginator** : This is a reference to the paginator instance. 519 | 520 | ### PAGINATION_READY 521 | 522 | 'paginationReady' 523 | 524 | This event is raised once the paginators collector has been setup and the message has been sent with the starting page. 525 | 526 | Parameters: (paginator) 527 | - **paginator** : This is a reference to the paginator instance. --------------------------------------------------------------------------------