From 2aea829b72e3ec9db2b4a47ae89b67137e32fc5b Mon Sep 17 00:00:00 2001 From: Monbrey Date: Sun, 4 Jul 2021 17:04:08 +1000 Subject: [PATCH] feat(MessageButton): builder and associate interfaces --- package-lock.json | 122 +++++++++++++- package.json | 1 + src/components/BaseComponent.ts | 27 +++ src/components/MessageButton.ts | 281 ++++++++++++++++++++++++++++++++ src/index.ts | 2 + tests/components.test.ts | 130 +++++++++++++++ 6 files changed, 556 insertions(+), 7 deletions(-) create mode 100644 src/components/BaseComponent.ts create mode 100644 src/components/MessageButton.ts create mode 100644 tests/components.test.ts diff --git a/package-lock.json b/package-lock.json index a1324735..35023fd2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,10 +6,11 @@ "packages": { "": { "name": "@discordjs/builders", - "version": "0.1.0", + "version": "0.1.1", "license": "Apache-2.0", "dependencies": { "discord-api-types": "^0.18.1", + "ow": "^0.26.0", "tslib": "^2.3.0" }, "devDependencies": { @@ -2420,6 +2421,17 @@ "node": ">= 8" } }, + "node_modules/@sindresorhus/is": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.0.1.tgz", + "integrity": "sha512-Qm9hBEBu18wt1PO2flE7LPb30BHMQt1eQgbV76YntdNk73XZGpn3izvGTYxbGgzXKgbCjiia0uxTd3aTNQrY/g==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, "node_modules/@sinonjs/commons": { "version": "1.8.3", "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.3.tgz", @@ -3224,7 +3236,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, "engines": { "node": ">=6" } @@ -5797,7 +5808,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", - "dev": true, "engines": { "node": ">=8" } @@ -7284,6 +7294,11 @@ "integrity": "sha1-gteb/zCmfEAF/9XiUVMArZyk168=", "dev": true }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha1-QVxEePK8wwEgwizhDtMib30+GOA=" + }, "node_modules/lodash.ismatch": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/lodash.ismatch/-/lodash.ismatch-4.4.0.tgz", @@ -8071,6 +8086,50 @@ "node": ">= 0.8.0" } }, + "node_modules/ow": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/ow/-/ow-0.26.0.tgz", + "integrity": "sha512-22YUQW9d6oUSCpIQuBV25djtC1uMtpWqmtUYnuh2UHWeNMpppCFCvq3eSBIWWMDbe2UVq26kWYvBHDzOIu5NYg==", + "dependencies": { + "@sindresorhus/is": "^4.0.1", + "callsites": "^3.1.0", + "dot-prop": "^6.0.1", + "lodash.isequal": "^4.5.0", + "type-fest": "^1.2.1", + "vali-date": "^1.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ow/node_modules/dot-prop": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-6.0.1.tgz", + "integrity": "sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA==", + "dependencies": { + "is-obj": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ow/node_modules/type-fest": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-1.2.1.tgz", + "integrity": "sha512-SbmIRuXhJs8KTneu77Ecylt9zuqL683tuiLYpTRil4H++eIhqCmx6ko6KAFem9dty8sOdnEiX7j4K1nRE628fQ==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/p-each-series": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-each-series/-/p-each-series-2.2.0.tgz", @@ -9904,6 +9963,14 @@ "node": ">= 8" } }, + "node_modules/vali-date": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/vali-date/-/vali-date-1.0.0.tgz", + "integrity": "sha1-G5BKWWCfsyjvB4E4Qgk09rhnCaY=", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/validate-npm-package-license": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", @@ -11895,6 +11962,11 @@ "fastq": "^1.6.0" } }, + "@sindresorhus/is": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.0.1.tgz", + "integrity": "sha512-Qm9hBEBu18wt1PO2flE7LPb30BHMQt1eQgbV76YntdNk73XZGpn3izvGTYxbGgzXKgbCjiia0uxTd3aTNQrY/g==" + }, "@sinonjs/commons": { "version": "1.8.3", "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.3.tgz", @@ -12512,8 +12584,7 @@ "callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==" }, "camelcase": { "version": "5.3.1", @@ -14444,8 +14515,7 @@ "is-obj": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", - "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", - "dev": true + "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==" }, "is-plain-obj": { "version": "1.1.0", @@ -15582,6 +15652,11 @@ "integrity": "sha1-gteb/zCmfEAF/9XiUVMArZyk168=", "dev": true }, + "lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha1-QVxEePK8wwEgwizhDtMib30+GOA=" + }, "lodash.ismatch": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/lodash.ismatch/-/lodash.ismatch-4.4.0.tgz", @@ -16183,6 +16258,34 @@ "word-wrap": "^1.2.3" } }, + "ow": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/ow/-/ow-0.26.0.tgz", + "integrity": "sha512-22YUQW9d6oUSCpIQuBV25djtC1uMtpWqmtUYnuh2UHWeNMpppCFCvq3eSBIWWMDbe2UVq26kWYvBHDzOIu5NYg==", + "requires": { + "@sindresorhus/is": "^4.0.1", + "callsites": "^3.1.0", + "dot-prop": "^6.0.1", + "lodash.isequal": "^4.5.0", + "type-fest": "^1.2.1", + "vali-date": "^1.0.0" + }, + "dependencies": { + "dot-prop": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-6.0.1.tgz", + "integrity": "sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA==", + "requires": { + "is-obj": "^2.0.0" + } + }, + "type-fest": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-1.2.1.tgz", + "integrity": "sha512-SbmIRuXhJs8KTneu77Ecylt9zuqL683tuiLYpTRil4H++eIhqCmx6ko6KAFem9dty8sOdnEiX7j4K1nRE628fQ==" + } + } + }, "p-each-series": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-each-series/-/p-each-series-2.2.0.tgz", @@ -17569,6 +17672,11 @@ } } }, + "vali-date": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/vali-date/-/vali-date-1.0.0.tgz", + "integrity": "sha1-G5BKWWCfsyjvB4E4Qgk09rhnCaY=" + }, "validate-npm-package-license": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", diff --git a/package.json b/package.json index 32583a22..fefa8e69 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "homepage": "https://github.com/discordjs/builders#readme", "dependencies": { "discord-api-types": "^0.18.1", + "ow": "^0.26.0", "tslib": "^2.3.0" }, "devDependencies": { diff --git a/src/components/BaseComponent.ts b/src/components/BaseComponent.ts new file mode 100644 index 00000000..e42f7230 --- /dev/null +++ b/src/components/BaseComponent.ts @@ -0,0 +1,27 @@ +/** + * https://discord.com/developers/docs/interactions/message-components + */ +export interface APIBaseComponent { + /** + * The type of the component + */ + type: ComponentType; +} + +/** + * https://discord.com/developers/docs/interactions/message-components#component-types + */ +export enum ComponentType { + /** + * ActionRow component + */ + ActionRow = 1, + /** + * Button component + */ + Button, + /** + * Select Menu component + */ + SelectMenu, +} diff --git a/src/components/MessageButton.ts b/src/components/MessageButton.ts new file mode 100644 index 00000000..ebab49ea --- /dev/null +++ b/src/components/MessageButton.ts @@ -0,0 +1,281 @@ +import type { APIPartialEmoji, Snowflake } from 'discord-api-types/v8'; +import ow from 'ow'; +import { APIBaseComponent, ComponentType } from './BaseComponent'; + +/** + * Builds a button-type message component + */ +export class MessageButton { + /** + * The text to be displayed on this button + * @type {?string} + */ + public customID?: string; + + /** + * Whether this button is currently disabled + * @type {boolean} + */ + public disabled?: boolean; + + /** + * Emoji for this button + * @type {?RawEmoji} + */ + public emoji?: APIPartialEmoji; + + /** + * The text to be displayed on this button + * @type {?string} + */ + public label?: string; + + /** + * The style of this button + * @type {?MessageButtonStyle} + */ + public style?: MessageButtonStyle; + + /** + * The URL this button links to, if it is a Link style button + * @type {?string} + */ + public url?: string; + + /** + * @param {MessageButtonData|APIButtonComponent} [data] MessageButton to clone or raw data + */ + public constructor(data?: MessageButtonData | APIButtonComponent) { + if (!data) return; + + this.style = MessageButton.resolveStyle(data.style); + + if ('custom_id' in data && typeof data.custom_id !== 'undefined') { + this.setCustomID(data.custom_id); + } else if ('customID' in data && typeof data.customID !== 'undefined') { + this.setCustomID(data.customID); + } + + if (typeof data.label !== 'undefined') { + this.setLabel(data.label); + } + + if (typeof data.emoji !== 'undefined') { + this.setEmoji(data.emoji); + } + + if (typeof data.url !== 'undefined') { + this.setURL(data.url); + } + + this.setDisabled(data.disabled ?? false); + } + + /** + * Sets the custom ID of this button + * @param {string} customID A unique string to be sent in the interaction when clicked + * @returns {MessageButton} + */ + public setCustomID(customID: string): this { + ow(customID, ow.string.nonEmpty.maxLength(100)); + ow(this.style, ow.optional.number.inRange(1, 4).message('Cannot set customID on a button that is Link style.')); + this.customID = customID; + return this; + } + + /** + * Sets the interactive status of the button + * @param {boolean} disabled Whether this button should be disabled + * @returns {MessageButton} + */ + public setDisabled(disabled: boolean): this { + ow(disabled, ow.boolean); + this.disabled = disabled; + return this; + } + + /** + * Set the emoji of this button + * @param {EmojiResolvable} emoji The emoji to be displayed on this button + * @returns {MessageButton} + */ + public setEmoji(emoji: EmojiResolvable): this { + const _emoji = MessageButton.resolveEmoji(emoji); + ow(_emoji, ow.object.hasAnyKeys('id', 'name')); + this.emoji = _emoji; + return this; + } + + /** + * Sets the label of this button + * @param {string} label The text to be displayed on this button + * @returns {MessageButton} + */ + public setLabel(label: string): this { + ow(label, ow.string.nonEmpty.maxLength(80)); + this.label = label; + return this; + } + + /** + * Sets the style of this button + * @param {MessageButtonStyleResolvable} style The style of this button + * @returns {MessageButton} + */ + public setStyle(style: MessageButtonStyleResolveable): this { + this.style = MessageButton.resolveStyle(style); + return this; + } + + /** + * Sets the URL of this button. + * MessageButton#style must be LINK when setting a URL + * @param {string} url The URL of this button + * @returns {MessageButton} + */ + public setURL(url: string) { + ow(url, ow.string.nonEmpty); + ow(this.style, ow.optional.number.equal(5).message('Cannot set URL on a button that is not Link style.')); + this.url = url; + return this; + } + + /** + * Transforms the button to a plain object. + * @returns {APIButtonComponent} The raw data of this button + */ + public toJSON() { + ow(this.style, ow.number); + return { + custom_id: this.customID, + disabled: this.disabled, + emoji: this.emoji, + label: this.label, + style: this.style, + type: ComponentType.Button, + url: this.url, + }; + } + + /** + * Resolves the style of a button + * @param {MessageButtonStyleResolvable} style The style to resolve + * @returns {MessageButtonStyle} + * @private + */ + private static resolveStyle(style: MessageButtonStyleResolveable): MessageButtonStyle { + ow( + style, + ow.any( + ow.number.inRange(1, 5), + ow.string.is((value) => ['primary', 'secondary', 'success', 'danger', 'link'].includes(value.toLowerCase())), + ), + ); + return typeof style === 'number' ? style : MessageButtonStyle[style]; + } + + /** + * Resolves a partial emoji from a variety of inputs + * @param {EmojiResolvable} emoji The data to resolve + * @returns {APIPartialEmoji} + * @private + */ + private static resolveEmoji(emoji: EmojiResolvable): APIPartialEmoji | null { + if (!emoji) return null; + if (typeof emoji === 'string') { + if (/^\d{17,19}$/.test(emoji)) return { id: `${BigInt(emoji)}`, name: null }; + if (!emoji.includes(':')) return { animated: false, name: emoji, id: null }; + const match = /?/.exec(emoji); + return match && { animated: Boolean(match[1]), name: match[2], id: match[3] ? `${BigInt(match[3])}` : null }; + } + const { id, name, animated } = emoji; + if (!id && !name) return null; + return { id, name, animated }; + } +} + +/** + * Data that can be resolved to an APIPartialEmoji + */ +export type EmojiResolvable = string | Snowflake | APIPartialEmoji; + +/** + * Data for a Message Button + */ +export interface MessageButtonData { + /** + * The custom_id to be sent in the interaction when clicked + */ + customID?: string; + /** + * The status of the button + */ + disabled?: boolean; + /** + * The emoji to display to the left of the text + */ + emoji?: EmojiResolvable; + /** + * The label to be displayed on the button + */ + label?: string; + /** + * The style of the button + */ + style: MessageButtonStyleResolveable; + + /** + * The URL to direct users to when clicked for Link buttons + */ + url?: string; +} + +/** + * https://discord.com/developers/docs/interactions/message-components#buttons-button-object + */ +export interface APIButtonComponent extends APIBaseComponent { + /** + * The type of the component + */ + type: ComponentType.Button; + /** + * The label to be displayed on the button + */ + label?: string; + /** + * The custom_id to be sent in the interaction when clicked + */ + custom_id?: string; + /** + * The style of the button + */ + style: MessageButtonStyle; + /** + * The emoji to display to the left of the text + */ + emoji?: APIPartialEmoji; + /** + * The URL to direct users to when clicked for Link buttons + */ + url?: string; + /** + * The status of the button + */ + disabled?: boolean; +} + +/** + * https://discord.com/developers/docs/interactions/message-components#buttons-button-styles + */ +export enum MessageButtonStyle { + Primary = 1, + Secondary, + Success, + Danger, + Link, +} + +/** + * Data that can be resolved to s ButtonStyle + */ +export type MessageButtonStyleResolveable = keyof typeof MessageButtonStyle | MessageButtonStyle; diff --git a/src/index.ts b/src/index.ts index 71722ceb..8cd9325e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1 +1,3 @@ +export * from './components/BaseComponent'; +export * from './components/MessageButton'; export * from './messages/formatters'; diff --git a/tests/components.test.ts b/tests/components.test.ts new file mode 100644 index 00000000..42733559 --- /dev/null +++ b/tests/components.test.ts @@ -0,0 +1,130 @@ +import { ArgumentError } from 'ow/dist'; +import { MessageButton } from '../src'; + +describe('Components', () => { + describe('MessageButton', () => { + describe('constructor', () => { + test('GIVEN data THEN class properties are set', () => { + const button = new MessageButton({ + customID: 'discord.js', + emoji: '⭐', + label: 'discord.js', + style: 'Primary', + }); + expect(button.customID).toBe('discord.js'); + expect(button.emoji).toMatchObject({ id: null, name: '⭐' }); + expect(button.label).toBe('discord.js'); + expect(button.style).toBe(1); + }); + + test('GIVEN raw data THEN customID is set to custom_id', () => { + const button = new MessageButton({ + custom_id: 'discord.js', + style: 2, + }); + expect(button.customID).toBe('discord.js'); + expect(button.style).toBe(2); + }); + + test('GIVEN data for link button THEN class properties are set', () => { + const button = new MessageButton({ + url: 'https://discord.js.org', + label: 'discord.js', + style: 'Link', + }); + expect(button.url).toBe('https://discord.js.org'); + }); + }); + + describe('setLabel', () => { + test('GIVEN label > 80 characters THEN should throw ArgumentError', () => { + expect(() => new MessageButton().setLabel('a'.repeat(81))).toThrow(ArgumentError); + }); + }); + + describe('setStyle', () => { + test('GIVEN "Primary" THEN should set style to 1', () => { + expect(new MessageButton().setStyle('Primary').style).toBe(1); + }); + + test('GIVEN 6 THEN should throw ArgumentError', () => { + expect(() => new MessageButton().setStyle(6)).toThrow(ArgumentError); + }); + }); + + describe('setEmoji', () => { + test('GIVEN emoji object THEN sets emoji property', () => { + expect( + new MessageButton().setEmoji({ + id: '850901572418666558', + name: 'charmander', + }).emoji, + ).toMatchObject({ + id: '850901572418666558', + name: 'charmander', + }); + }); + + test('GIVEN emoji id THEN sets emoji id property', () => { + expect(new MessageButton().setEmoji('850901572418666558').emoji).toMatchObject({ + id: '850901572418666558', + }); + }); + + test('GIVEN unicode THEN sets emoji name property', () => { + expect(new MessageButton().setEmoji('⭐').emoji).toMatchObject({ + name: '⭐', + }); + }); + + test('GIVEN custom emoji string THEN sets emoji property', () => { + expect(new MessageButton().setEmoji('<:charmander:850901572418666558>').emoji).toMatchObject({ + id: '850901572418666558', + name: 'charmander', + animated: false, + }); + }); + + test('GIVEN emoji name THEN sets emoji property', () => { + expect(new MessageButton().setEmoji(':charmander:').emoji).toMatchObject({ + name: 'charmander', + }); + }); + + test('GIVEN no param THEN throws ArgumentError', () => { + // @ts-expect-error + expect(() => new MessageButton().setEmoji()).toThrow(ArgumentError); + }); + + test('GIVEN non-emoji object THEN throws ArgumentError', () => { + // @ts-expect-error + expect(() => new MessageButton().setEmoji({})).toThrow(ArgumentError); + }); + }); + + describe('toJSON', () => { + test('should throw ArgumentError if style not set', () => { + expect(() => new MessageButton().toJSON()).toThrow(ArgumentError); + }); + + test('should return MessageButtonDataRaw', () => { + expect( + new MessageButton({ + style: 'Primary', + customID: 'discord.js', + label: 'Labelled', + disabled: true, + emoji: '⭐', + }).toJSON(), + ).toMatchObject({ + custom_id: 'discord.js', + disabled: true, + emoji: { id: null, name: '⭐' }, + label: 'Labelled', + style: 1, + type: 2, + }); + }); + }); + }); +});