Skip to content

Commit

Permalink
feat(modals): modals, input text components and modal submits, v13 st…
Browse files Browse the repository at this point in the history
  • Loading branch information
GamingGeek committed Feb 10, 2022
1 parent cd93f5e commit 0ce4318
Show file tree
Hide file tree
Showing 17 changed files with 752 additions and 30 deletions.
3 changes: 3 additions & 0 deletions src/client/actions/InteractionCreate.js
Expand Up @@ -53,6 +53,9 @@ class InteractionCreateAction extends Action {
case InteractionTypes.APPLICATION_COMMAND_AUTOCOMPLETE:
InteractionType = Structures.get('AutocompleteInteraction');
break;
case InteractionTypes.MODAL_SUBMIT:
InteractionType = Structures.get('ModalSubmitInteraction');
break;
default:
client.emit(Events.DEBUG, `[INTERACTION] Received interaction with unknown type: ${data.type}`);
return;
Expand Down
12 changes: 12 additions & 0 deletions src/errors/Messages.js
Expand Up @@ -56,6 +56,14 @@ const Messages = {
SELECT_OPTION_VALUE: 'MessageSelectOption value must be a string',
SELECT_OPTION_DESCRIPTION: 'MessageSelectOption description must be a string',

TEXT_INPUT_CUSTOM_ID: 'TextInputComponent customId must be a string',
TEXT_INPUT_LABEL: 'TextInputComponent label must be a string',
TEXT_INPUT_PLACEHOLDER: 'TextInputComponent placeholder must be a string',
TEXT_INPUT_VALUE: 'TextInputComponent value must be a string',

MODAL_CUSTOM_ID: 'Modal customId must be a string',
MODAL_TITLE: 'Modal title must be a string',

INTERACTION_COLLECTOR_ERROR: reason => `Collector received no interactions before ending with reason: ${reason}`,

FILE_NOT_FOUND: file => `File could not be found: ${file}`,
Expand Down Expand Up @@ -145,6 +153,10 @@ const Messages = {
COMMAND_INTERACTION_OPTION_NO_SUB_COMMAND_GROUP: 'No subcommand group specified for interaction.',
AUTOCOMPLETE_INTERACTION_OPTION_NO_FOCUSED_OPTION: 'No focused option for autocomplete interaction.',

MODAL_SUBMIT_INTERACTION_FIELD_NOT_FOUND: customId => `Required field with custom id "${customId}" not found.`,
MODAL_SUBMIT_INTERACTION_FIELD_TYPE: (customId, type, expected) =>
`Field with custom id "${customId}" is of type: ${type}; expected ${expected}.`,

INVITE_MISSING_SCOPES: 'At least one valid scope must be provided for the invite',

NOT_IMPLEMENTED: (what, name) => `Method ${what} not implemented on ${name}.`,
Expand Down
3 changes: 3 additions & 0 deletions src/index.js
Expand Up @@ -119,6 +119,8 @@ exports.MessageMentions = require('./structures/MessageMentions');
exports.MessagePayload = require('./structures/MessagePayload');
exports.MessageReaction = require('./structures/MessageReaction');
exports.MessageSelectMenu = require('./structures/MessageSelectMenu');
exports.Modal = require('./structures/Modal');
exports.ModalSubmitInteraction = require('./structures/ModalSubmitInteraction');
exports.NewsChannel = require('./structures/NewsChannel');
exports.OAuth2Guild = require('./structures/OAuth2Guild');
exports.PartialGroupDMChannel = require('./structures/PartialGroupDMChannel');
Expand All @@ -138,6 +140,7 @@ exports.Structures = require('./util/Structures');
exports.Team = require('./structures/Team');
exports.TeamMember = require('./structures/TeamMember');
exports.TextChannel = require('./structures/TextChannel');
exports.TextInputComponent = require('./structures/TextInputComponent');
exports.ThreadChannel = require('./structures/ThreadChannel');
exports.ThreadMember = require('./structures/ThreadMember');
exports.Typing = require('./structures/Typing');
Expand Down
17 changes: 12 additions & 5 deletions src/structures/BaseMessageComponent.js
Expand Up @@ -4,7 +4,7 @@ const { TypeError } = require('../errors');
const { MessageComponentTypes, Events } = require('../util/Constants');

/**
* Represents an interactive component of a Message. It should not be necessary to construct this directly.
* Represents an interactive component of a Message or Modal. It should not be necessary to construct this directly.
* See {@link MessageComponent}
*/
class BaseMessageComponent {
Expand All @@ -15,18 +15,20 @@ class BaseMessageComponent {
*/

/**
* Data that can be resolved into options for a MessageComponent. This can be:
* Data that can be resolved into options for a component. This can be:
* * MessageActionRowOptions
* * MessageButtonOptions
* * MessageSelectMenuOptions
* * TextInputComponentOptions
* @typedef {MessageActionRowOptions|MessageButtonOptions|MessageSelectMenuOptions} MessageComponentOptions
*/

/**
* Components that can be sent in a message. These can be:
* Components that can be sent in a payload. These can be:
* * MessageActionRow
* * MessageButton
* * MessageSelectMenu
* * TextInputComponent
* @typedef {MessageActionRow|MessageButton|MessageSelectMenu} MessageComponent
* @see {@link https://discord.com/developers/docs/interactions/message-components#component-object-component-types}
*/
Expand All @@ -51,7 +53,7 @@ class BaseMessageComponent {
}

/**
* Constructs a MessageComponent based on the type of the incoming data
* Constructs a component based on the type of the incoming data
* @param {MessageComponentOptions} data Data for a MessageComponent
* @param {Client|WebhookClient} [client] Client constructing this component
* @returns {?MessageComponent}
Expand Down Expand Up @@ -79,6 +81,11 @@ class BaseMessageComponent {
component = new MessageSelectMenu(data);
break;
}
case MessageComponentTypes.TEXT_INPUT: {
const TextInputComponent = require('./TextInputComponent');
component = data instanceof TextInputComponent ? data : new TextInputComponent(data);
break;
}
default:
if (client) {
client.emit(Events.DEBUG, `[BaseMessageComponent] Received component with unknown type: ${data.type}`);
Expand All @@ -90,7 +97,7 @@ class BaseMessageComponent {
}

/**
* Resolves the type of a MessageComponent
* Resolves the type of a component
* @param {MessageComponentTypeResolvable} type The type to resolve
* @returns {MessageComponentType}
* @private
Expand Down
8 changes: 8 additions & 0 deletions src/structures/Interaction.js
Expand Up @@ -160,6 +160,14 @@ class Interaction extends Base {
return InteractionTypes[this.type] === InteractionTypes.APPLICATION_COMMAND && typeof this.targetId !== 'undefined';
}

/**
* Indicates whether this interaction is a {@link ModalSubmitInteraction}
* @returns {boolean}
*/
isModalSubmit() {
return InteractionTypes[this.type] === InteractionTypes.MODAL_SUBMIT;
}

/**
* Indicates whether this interaction is a {@link UserContextMenuInteraction}
* @returns {boolean}
Expand Down
6 changes: 4 additions & 2 deletions src/structures/MessageActionRow.js
Expand Up @@ -12,14 +12,16 @@ class MessageActionRow extends BaseMessageComponent {
* Components that can be placed in an action row
* * MessageButton
* * MessageSelectMenu
* @typedef {MessageButton|MessageSelectMenu} MessageActionRowComponent
* * TextInputComponent
* @typedef {MessageButton|MessageSelectMenu|TextInputComponent} MessageActionRowComponent
*/

/**
* Options for components that can be placed in an action row
* * MessageButtonOptions
* * MessageSelectMenuOptions
* @typedef {MessageButtonOptions|MessageSelectMenuOptions} MessageActionRowComponentOptions
* * TextInputComponentOptions
* @typedef {MessageButtonOptions|MessageSelectMenuOptions|TextInputComponentOptions} MessageActionRowComponentOptions
*/

/**
Expand Down
1 change: 1 addition & 0 deletions src/structures/MessageComponentInteraction.js
Expand Up @@ -101,6 +101,7 @@ class MessageComponentInteraction extends Interaction {
followUp() {}
deferUpdate() {}
update() {}
presentModal() {}
}

InteractionResponses.applyToClass(MessageComponentInteraction);
Expand Down
100 changes: 100 additions & 0 deletions src/structures/Modal.js
@@ -0,0 +1,100 @@
'use strict';

const BaseMessageComponent = require('./BaseMessageComponent');
const Util = require('../util/Util');

class Modal {
/**
* @typedef {Object} ModalOptions
* @property {string} [customId] A unique string to be sent in the interaction when clicked
* @property {string} [title] The title to be displayed on this modal
* @property {MessageActionRow[]|MessageActionRowOptions[]} [components]
* Action rows containing interactive components for the modal (text input components)
*/

/**
* @param {Modal|ModalOptions} data Modal to clone or raw data
* @param {Client} client The client constructing this Modal, if provided
*/
constructor(data = {}, client = null) {
/**
* A list of MessageActionRows in the modal
* @type {MessageActionRow[]}
*/
this.components = data.components?.map(c => BaseMessageComponent.create(c, client)) ?? [];

/**
* A unique string to be sent in the interaction when submitted
* @type {?string}
*/
this.customId = data.custom_id ?? data.customId ?? null;

/**
* The title to be displayed on this modal
* @type {?string}
*/
this.title = data.title ?? null;
}

/**
* Adds components to the modal.
* @param {...MessageActionRowResolvable[]} components The components to add
* @returns {Modal}
*/
addComponents(...components) {
this.components.push(...components.flat(Infinity).map(c => BaseMessageComponent.create(c)));
return this;
}

/**
* Sets the components of the modal.
* @param {...MessageActionRowResolvable[]} components The components to set
* @returns {Modal}
*/
setComponents(...components) {
this.spliceComponents(0, this.components.length, components);
return this;
}

/**
* Sets the custom id for this modal
* @param {string} customId A unique string to be sent in the interaction when submitted
* @returns {Modal}
*/
setCustomId(customId) {
this.customId = Util.verifyString(customId, RangeError, 'MODAL_CUSTOM_ID');
return this;
}

/**
* Removes, replaces, and inserts components in the modal.
* @param {number} index The index to start at
* @param {number} deleteCount The number of components to remove
* @param {...MessageActionRowResolvable[]} [components] The replacing components
* @returns {Modal}
*/
spliceComponents(index, deleteCount, ...components) {
this.components.splice(index, deleteCount, ...components.flat(Infinity).map(c => BaseMessageComponent.create(c)));
return this;
}

/**
* Sets the title of this modal
* @param {string} title The title to be displayed on this modal
* @returns {Modal}
*/
setTitle(title) {
this.title = Util.verifyString(title, RangeError, 'MODAL_TITLE');
return this;
}

toJSON() {
return {
components: this.components.map(c => c.toJSON()),
custom_id: this.customId,
title: this.title,
};
}
}

module.exports = Modal;
50 changes: 50 additions & 0 deletions src/structures/ModalSubmitFieldsResolver.js
@@ -0,0 +1,50 @@
'use strict';

const { TypeError } = require('../errors');
const { MessageComponentTypes } = require('../util/Constants');

class ModalSubmitFieldsResolver {
constructor(components) {
/**
* The components within the modal
* @type {PartialModalActionRow[]} The components in the modal
*/
this.components = components;
}

/**
* The extracted fields from the modal
* @type {PartialInputTextData[]} The fields in the modal
* @private
*/
get _fields() {
return this.components.reduce((previous, next) => previous.concat(next.components), []);
}

/**
* Gets a field given a custom id from a component
* @param {string} customId The custom id of the component
* @returns {?PartialInputTextData}
*/
getField(customId) {
const field = this._fields.find(f => f.customId === customId);
if (!field) throw new TypeError('MODAL_SUBMIT_INTERACTION_FIELD_NOT_FOUND', customId);
return field;
}

/**
* Gets the value of a text input component given a custom id
* @param {string} customId The custom id of the text input component
* @returns {?string}
*/
getTextInputValue(customId) {
const field = this.getField(customId);
const expectedType = MessageComponentTypes[MessageComponentTypes.TEXT_INPUT];
if (field.type !== expectedType) {
throw new TypeError('MODAL_SUBMIT_INTERACTION_FIELD_TYPE', customId, field.type, expectedType);
}
return field.value;
}
}

module.exports = ModalSubmitFieldsResolver;

0 comments on commit 0ce4318

Please sign in to comment.