From e8252ed3b981a4b7e4013f12efadd2f5d9318d3e Mon Sep 17 00:00:00 2001 From: Suneet Tipirneni <77477100+suneettipirneni@users.noreply.github.com> Date: Sun, 13 Feb 2022 07:06:11 -0500 Subject: [PATCH] refactor: make public builder props getters (#7422) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Jiralite <33201955+Jiralite@users.noreply.github.com> Co-authored-by: Antonio Román --- .../__tests__/components/actionRow.test.ts | 6 +- .../__tests__/components/selectMenu.test.ts | 16 +- .../builders/__tests__/messages/embed.test.ts | 69 ++----- packages/builders/src/components/ActionRow.ts | 25 +-- .../builders/src/components/Assertions.ts | 2 +- packages/builders/src/components/Component.ts | 27 ++- .../builders/src/components/Components.ts | 6 +- .../builders/src/components/button/Button.ts | 5 +- .../src/components/button/UnsafeButton.ts | 89 ++++++--- .../src/components/selectMenu/SelectMenu.ts | 4 +- .../components/selectMenu/SelectMenuOption.ts | 2 +- .../components/selectMenu/UnsafeSelectMenu.ts | 93 +++++---- .../selectMenu/UnsafeSelectMenuOption.ts | 64 +++++-- .../builders/src/messages/embed/Assertions.ts | 4 +- packages/builders/src/messages/embed/Embed.ts | 12 +- .../src/messages/embed/UnsafeEmbed.ts | 179 ++++++++++++------ packages/discord.js/typings/index.test-d.ts | 2 +- 17 files changed, 371 insertions(+), 234 deletions(-) diff --git a/packages/builders/__tests__/components/actionRow.test.ts b/packages/builders/__tests__/components/actionRow.test.ts index d55d501f6ab8..235259201930 100644 --- a/packages/builders/__tests__/components/actionRow.test.ts +++ b/packages/builders/__tests__/components/actionRow.test.ts @@ -5,7 +5,7 @@ describe('Action Row Components', () => { describe('Assertion Tests', () => { test('GIVEN valid components THEN do not throw', () => { expect(() => new ActionRow().addComponents(new ButtonComponent())).not.toThrowError(); - expect(() => new ActionRow().setComponents(new ButtonComponent())).not.toThrowError(); + expect(() => new ActionRow().setComponents([new ButtonComponent()])).not.toThrowError(); }); test('GIVEN valid JSON input THEN valid JSON output is given', () => { @@ -84,10 +84,10 @@ describe('Action Row Components', () => { .setCustomId('1234') .setMaxValues(10) .setMinValues(12) - .setOptions( + .setOptions([ new SelectMenuOption().setLabel('one').setValue('one'), new SelectMenuOption().setLabel('two').setValue('two'), - ); + ]); expect(new ActionRow().addComponents(button).toJSON()).toEqual(rowWithButtonData); expect(new ActionRow().addComponents(selectMenu).toJSON()).toEqual(rowWithSelectMenuData); diff --git a/packages/builders/__tests__/components/selectMenu.test.ts b/packages/builders/__tests__/components/selectMenu.test.ts index 102fc98784d3..6b4692df591c 100644 --- a/packages/builders/__tests__/components/selectMenu.test.ts +++ b/packages/builders/__tests__/components/selectMenu.test.ts @@ -23,7 +23,7 @@ describe('Button Components', () => { .setEmoji({ name: 'test' }) .setDescription('description'); expect(() => selectMenu().addOptions(option)).not.toThrowError(); - expect(() => selectMenu().setOptions(option)).not.toThrowError(); + expect(() => selectMenu().setOptions([option])).not.toThrowError(); }); test('GIVEN invalid inputs THEN Select Menu does throw', () => { @@ -55,17 +55,25 @@ describe('Button Components', () => { description: 'test', }; - const selectMenuData: APISelectMenuComponent = { + const selectMenuDataWithoutOptions = { type: ComponentType.SelectMenu, custom_id: 'test', max_values: 10, min_values: 3, disabled: true, - options: [selectMenuOptionData], placeholder: 'test', + } as const; + + const selectMenuData: APISelectMenuComponent = { + ...selectMenuDataWithoutOptions, + options: [selectMenuOptionData], }; - expect(new SelectMenuComponent(selectMenuData).toJSON()).toEqual(selectMenuData); + expect( + new SelectMenuComponent(selectMenuDataWithoutOptions) + .addOptions(new SelectMenuOption(selectMenuOptionData)) + .toJSON(), + ).toEqual(selectMenuData); expect(new SelectMenuOption(selectMenuOptionData).toJSON()).toEqual(selectMenuOptionData); }); }); diff --git a/packages/builders/__tests__/messages/embed.test.ts b/packages/builders/__tests__/messages/embed.test.ts index 3483ac797399..10520efb01bf 100644 --- a/packages/builders/__tests__/messages/embed.test.ts +++ b/packages/builders/__tests__/messages/embed.test.ts @@ -1,19 +1,4 @@ import { Embed } from '../../src'; -import type { APIEmbed } from 'discord-api-types/v9'; - -const emptyEmbed: APIEmbed = { - author: undefined, - color: undefined, - description: undefined, - fields: [], - footer: undefined, - image: undefined, - provider: undefined, - thumbnail: undefined, - title: undefined, - url: undefined, - video: undefined, -}; const alpha = 'abcdefghijklmnopqrstuvwxyz'; @@ -41,21 +26,21 @@ describe('Embed', () => { describe('Embed title', () => { test('GIVEN an embed with a pre-defined title THEN return valid toJSON data', () => { const embed = new Embed({ title: 'foo' }); - expect(embed.toJSON()).toStrictEqual({ ...emptyEmbed, title: 'foo' }); + expect(embed.toJSON()).toStrictEqual({ title: 'foo' }); }); test('GIVEN an embed using Embed#setTitle THEN return valid toJSON data', () => { const embed = new Embed(); embed.setTitle('foo'); - expect(embed.toJSON()).toStrictEqual({ ...emptyEmbed, title: 'foo' }); + expect(embed.toJSON()).toStrictEqual({ title: 'foo' }); }); test('GIVEN an embed with a pre-defined title THEN unset title THEN return valid toJSON data', () => { const embed = new Embed({ title: 'foo' }); embed.setTitle(null); - expect(embed.toJSON()).toStrictEqual({ ...emptyEmbed }); + expect(embed.toJSON()).toStrictEqual({ title: undefined }); }); test('GIVEN an embed with an invalid title THEN throws error', () => { @@ -67,22 +52,22 @@ describe('Embed', () => { describe('Embed description', () => { test('GIVEN an embed with a pre-defined description THEN return valid toJSON data', () => { - const embed = new Embed({ ...emptyEmbed, description: 'foo' }); - expect(embed.toJSON()).toStrictEqual({ ...emptyEmbed, description: 'foo' }); + const embed = new Embed({ description: 'foo' }); + expect(embed.toJSON()).toStrictEqual({ description: 'foo' }); }); test('GIVEN an embed using Embed#setDescription THEN return valid toJSON data', () => { const embed = new Embed(); embed.setDescription('foo'); - expect(embed.toJSON()).toStrictEqual({ ...emptyEmbed, description: 'foo' }); + expect(embed.toJSON()).toStrictEqual({ description: 'foo' }); }); test('GIVEN an embed with a pre-defined description THEN unset description THEN return valid toJSON data', () => { const embed = new Embed({ description: 'foo' }); embed.setDescription(null); - expect(embed.toJSON()).toStrictEqual({ ...emptyEmbed }); + expect(embed.toJSON()).toStrictEqual({ description: undefined }); }); test('GIVEN an embed with an invalid description THEN throws error', () => { @@ -96,7 +81,6 @@ describe('Embed', () => { test('GIVEN an embed with a pre-defined url THEN returns valid toJSON data', () => { const embed = new Embed({ url: 'https://discord.js.org/' }); expect(embed.toJSON()).toStrictEqual({ - ...emptyEmbed, url: 'https://discord.js.org/', }); }); @@ -106,7 +90,6 @@ describe('Embed', () => { embed.setURL('https://discord.js.org/'); expect(embed.toJSON()).toStrictEqual({ - ...emptyEmbed, url: 'https://discord.js.org/', }); }); @@ -115,7 +98,7 @@ describe('Embed', () => { const embed = new Embed({ url: 'https://discord.js.org' }); embed.setURL(null); - expect(embed.toJSON()).toStrictEqual({ ...emptyEmbed }); + expect(embed.toJSON()).toStrictEqual({ url: undefined }); }); test('GIVEN an embed with an invalid URL THEN throws error', () => { @@ -128,21 +111,21 @@ describe('Embed', () => { describe('Embed Color', () => { test('GIVEN an embed with a pre-defined color THEN returns valid toJSON data', () => { const embed = new Embed({ color: 0xff0000 }); - expect(embed.toJSON()).toStrictEqual({ ...emptyEmbed, color: 0xff0000 }); + expect(embed.toJSON()).toStrictEqual({ color: 0xff0000 }); }); test('GIVEN an embed using Embed#setColor THEN returns valid toJSON data', () => { const embed = new Embed(); embed.setColor(0xff0000); - expect(embed.toJSON()).toStrictEqual({ ...emptyEmbed, color: 0xff0000 }); + expect(embed.toJSON()).toStrictEqual({ color: 0xff0000 }); }); test('GIVEN an embed with a pre-defined color THEN unset color THEN return valid toJSON data', () => { const embed = new Embed({ color: 0xff0000 }); embed.setColor(null); - expect(embed.toJSON()).toStrictEqual({ ...emptyEmbed }); + expect(embed.toJSON()).toStrictEqual({ color: undefined }); }); test('GIVEN an embed with an invalid color THEN throws error', () => { @@ -158,35 +141,35 @@ describe('Embed', () => { test('GIVEN an embed with a pre-defined timestamp THEN returns valid toJSON data', () => { const embed = new Embed({ timestamp: now.toISOString() }); - expect(embed.toJSON()).toStrictEqual({ ...emptyEmbed, timestamp: now.toISOString() }); + expect(embed.toJSON()).toStrictEqual({ timestamp: now.toISOString() }); }); test('given an embed using Embed#setTimestamp (with Date) THEN returns valid toJSON data', () => { const embed = new Embed(); embed.setTimestamp(now); - expect(embed.toJSON()).toStrictEqual({ ...emptyEmbed, timestamp: now.toISOString() }); + expect(embed.toJSON()).toStrictEqual({ timestamp: now.toISOString() }); }); test('GIVEN an embed using Embed#setTimestamp (with int) THEN returns valid toJSON data', () => { const embed = new Embed(); embed.setTimestamp(now.getTime()); - expect(embed.toJSON()).toStrictEqual({ ...emptyEmbed, timestamp: now.toISOString() }); + expect(embed.toJSON()).toStrictEqual({ timestamp: now.toISOString() }); }); test('GIVEN an embed using Embed#setTimestamp (default) THEN returns valid toJSON data', () => { const embed = new Embed(); embed.setTimestamp(); - expect(embed.toJSON()).toStrictEqual({ ...emptyEmbed, timestamp: embed.timestamp }); + expect(embed.toJSON()).toStrictEqual({ timestamp: embed.timestamp }); }); test('GIVEN an embed with a pre-defined timestamp THEN unset timestamp THEN return valid toJSON data', () => { const embed = new Embed({ timestamp: now.toISOString() }); embed.setTimestamp(null); - expect(embed.toJSON()).toStrictEqual({ ...emptyEmbed, timestamp: undefined }); + expect(embed.toJSON()).toStrictEqual({ timestamp: undefined }); }); }); @@ -194,7 +177,6 @@ describe('Embed', () => { test('GIVEN an embed with a pre-defined thumbnail THEN returns valid toJSON data', () => { const embed = new Embed({ thumbnail: { url: 'https://discord.js.org/static/logo.svg' } }); expect(embed.toJSON()).toStrictEqual({ - ...emptyEmbed, thumbnail: { url: 'https://discord.js.org/static/logo.svg' }, }); }); @@ -204,7 +186,6 @@ describe('Embed', () => { embed.setThumbnail('https://discord.js.org/static/logo.svg'); expect(embed.toJSON()).toStrictEqual({ - ...emptyEmbed, thumbnail: { url: 'https://discord.js.org/static/logo.svg' }, }); }); @@ -213,7 +194,7 @@ describe('Embed', () => { const embed = new Embed({ thumbnail: { url: 'https://discord.js.org/static/logo.svg' } }); embed.setThumbnail(null); - expect(embed.toJSON()).toStrictEqual({ ...emptyEmbed }); + expect(embed.toJSON()).toStrictEqual({ thumbnail: undefined }); }); test('GIVEN an embed with an invalid thumbnail THEN throws error', () => { @@ -227,7 +208,6 @@ describe('Embed', () => { test('GIVEN an embed with a pre-defined image THEN returns valid toJSON data', () => { const embed = new Embed({ image: { url: 'https://discord.js.org/static/logo.svg' } }); expect(embed.toJSON()).toStrictEqual({ - ...emptyEmbed, image: { url: 'https://discord.js.org/static/logo.svg' }, }); }); @@ -237,7 +217,6 @@ describe('Embed', () => { embed.setImage('https://discord.js.org/static/logo.svg'); expect(embed.toJSON()).toStrictEqual({ - ...emptyEmbed, image: { url: 'https://discord.js.org/static/logo.svg' }, }); }); @@ -246,7 +225,7 @@ describe('Embed', () => { const embed = new Embed({ image: { url: 'https://discord.js/org/static/logo.svg' } }); embed.setImage(null); - expect(embed.toJSON()).toStrictEqual({ ...emptyEmbed }); + expect(embed.toJSON()).toStrictEqual({ image: undefined }); }); test('GIVEN an embed with an invalid image THEN throws error', () => { @@ -262,7 +241,6 @@ describe('Embed', () => { author: { name: 'Wumpus', icon_url: 'https://discord.js.org/static/logo.svg', url: 'https://discord.js.org' }, }); expect(embed.toJSON()).toStrictEqual({ - ...emptyEmbed, author: { name: 'Wumpus', icon_url: 'https://discord.js.org/static/logo.svg', url: 'https://discord.js.org' }, }); }); @@ -276,7 +254,6 @@ describe('Embed', () => { }); expect(embed.toJSON()).toStrictEqual({ - ...emptyEmbed, author: { name: 'Wumpus', icon_url: 'https://discord.js.org/static/logo.svg', url: 'https://discord.js.org' }, }); }); @@ -287,7 +264,7 @@ describe('Embed', () => { }); embed.setAuthor(null); - expect(embed.toJSON()).toStrictEqual({ ...emptyEmbed }); + expect(embed.toJSON()).toStrictEqual({ author: undefined }); }); test('GIVEN an embed with an invalid author name THEN throws error', () => { @@ -303,7 +280,6 @@ describe('Embed', () => { footer: { text: 'Wumpus', icon_url: 'https://discord.js.org/static/logo.svg' }, }); expect(embed.toJSON()).toStrictEqual({ - ...emptyEmbed, footer: { text: 'Wumpus', icon_url: 'https://discord.js.org/static/logo.svg' }, }); }); @@ -313,7 +289,6 @@ describe('Embed', () => { embed.setFooter({ text: 'Wumpus', iconURL: 'https://discord.js.org/static/logo.svg' }); expect(embed.toJSON()).toStrictEqual({ - ...emptyEmbed, footer: { text: 'Wumpus', icon_url: 'https://discord.js.org/static/logo.svg' }, }); }); @@ -322,7 +297,7 @@ describe('Embed', () => { const embed = new Embed({ footer: { text: 'Wumpus', icon_url: 'https://discord.js.org/static/logo.svg' } }); embed.setFooter(null); - expect(embed.toJSON()).toStrictEqual({ ...emptyEmbed }); + expect(embed.toJSON()).toStrictEqual({ footer: undefined }); }); test('GIVEN an embed with invalid footer text THEN throws error', () => { @@ -338,7 +313,6 @@ describe('Embed', () => { fields: [{ name: 'foo', value: 'bar', inline: undefined }], }); expect(embed.toJSON()).toStrictEqual({ - ...emptyEmbed, fields: [{ name: 'foo', value: 'bar', inline: undefined }], }); }); @@ -348,7 +322,6 @@ describe('Embed', () => { embed.addField({ name: 'foo', value: 'bar' }); expect(embed.toJSON()).toStrictEqual({ - ...emptyEmbed, fields: [{ name: 'foo', value: 'bar', inline: undefined }], }); }); @@ -358,7 +331,6 @@ describe('Embed', () => { embed.addFields({ name: 'foo', value: 'bar' }); expect(embed.toJSON()).toStrictEqual({ - ...emptyEmbed, fields: [{ name: 'foo', value: 'bar', inline: undefined }], }); }); @@ -368,7 +340,6 @@ describe('Embed', () => { embed.addFields({ name: 'foo', value: 'bar' }, { name: 'foo', value: 'baz' }); expect(embed.spliceFields(0, 1).toJSON()).toStrictEqual({ - ...emptyEmbed, fields: [{ name: 'foo', value: 'baz', inline: undefined }], }); }); diff --git a/packages/builders/src/components/ActionRow.ts b/packages/builders/src/components/ActionRow.ts index 12cac4a21d04..2789bae26144 100644 --- a/packages/builders/src/components/ActionRow.ts +++ b/packages/builders/src/components/ActionRow.ts @@ -1,6 +1,6 @@ -import { APIActionRowComponent, ComponentType } from 'discord-api-types/v9'; +import { type APIActionRowComponent, ComponentType } from 'discord-api-types/v9'; import type { ButtonComponent, SelectMenuComponent } from '..'; -import type { Component } from './Component'; +import { Component } from './Component'; import { createComponent } from './Components'; export type MessageComponent = ActionRowComponent | ActionRow; @@ -8,16 +8,17 @@ export type MessageComponent = ActionRowComponent | ActionRow; export type ActionRowComponent = ButtonComponent | SelectMenuComponent; // TODO: Add valid form component types - /** * Represents an action row component */ -export class ActionRow implements Component { - public readonly components: T[] = []; - public readonly type = ComponentType.ActionRow; - - public constructor(data?: APIActionRowComponent & { type?: ComponentType.ActionRow }) { - this.components = (data?.components.map(createComponent) ?? []) as T[]; +export class ActionRow extends Component< + Omit & { type: ComponentType.ActionRow }, 'components'> +> { + public readonly components: T[]; + + public constructor({ components, ...data }: Partial = {}) { + super({ type: ComponentType.ActionRow, ...data }); + this.components = (components?.map((c) => createComponent(c)) ?? []) as T[]; } /** @@ -34,14 +35,14 @@ export class ActionRow implem * Sets the components in this action row * @param components The components to set this row to */ - public setComponents(...components: T[]) { - Reflect.set(this, 'components', [...components]); + public setComponents(components: T[]) { + this.components.splice(0, this.components.length, ...components); return this; } public toJSON(): APIActionRowComponent { return { - ...this, + ...this.data, components: this.components.map((component) => component.toJSON()), }; } diff --git a/packages/builders/src/components/Assertions.ts b/packages/builders/src/components/Assertions.ts index 9348bc9763c7..2f45e17832e7 100644 --- a/packages/builders/src/components/Assertions.ts +++ b/packages/builders/src/components/Assertions.ts @@ -40,7 +40,7 @@ export function validateRequiredSelectMenuOptionParameters(label?: string, value export const urlValidator = z.string().url(); export function validateRequiredButtonParameters( - style: ButtonStyle, + style?: ButtonStyle, label?: string, emoji?: APIMessageComponentEmoji, customId?: string, diff --git a/packages/builders/src/components/Component.ts b/packages/builders/src/components/Component.ts index 992c9199d445..ed5da17fc0f2 100644 --- a/packages/builders/src/components/Component.ts +++ b/packages/builders/src/components/Component.ts @@ -1,12 +1,33 @@ -import type { APIMessageComponent, ComponentType } from 'discord-api-types/v9'; import type { JSONEncodable } from '../util/jsonEncodable'; +import type { APIBaseMessageComponent, APIMessageComponent, ComponentType } from 'discord-api-types/v9'; /** * Represents a discord component */ -export interface Component extends JSONEncodable { +export abstract class Component< + DataType extends Partial> & { + type: ComponentType; + } = APIBaseMessageComponent, +> implements JSONEncodable +{ + /** + * The API data associated with this component + */ + protected readonly data: DataType; + + /** + * Converts this component to an API-compatible JSON object + */ + public abstract toJSON(): APIMessageComponent; + + public constructor(data: DataType) { + this.data = data; + } + /** * The type of this component */ - readonly type: ComponentType; + public get type(): DataType['type'] { + return this.data.type; + } } diff --git a/packages/builders/src/components/Components.ts b/packages/builders/src/components/Components.ts index f0cb5d95eebb..cb61639bb545 100644 --- a/packages/builders/src/components/Components.ts +++ b/packages/builders/src/components/Components.ts @@ -19,11 +19,11 @@ export function createComponent(data: C): C; export function createComponent(data: APIMessageComponent | MessageComponent): Component { switch (data.type) { case ComponentType.ActionRow: - return data instanceof ActionRow ? data : new ActionRow(data); + return (data instanceof ActionRow ? data : new ActionRow(data)) as Component; case ComponentType.Button: - return data instanceof ButtonComponent ? data : new ButtonComponent(data); + return (data instanceof ButtonComponent ? data : new ButtonComponent(data)) as Component; case ComponentType.SelectMenu: - return data instanceof SelectMenuComponent ? data : new SelectMenuComponent(data); + return (data instanceof SelectMenuComponent ? data : new SelectMenuComponent(data)) as Component; default: // @ts-expect-error throw new Error(`Cannot serialize component type: ${data.type as number}`); diff --git a/packages/builders/src/components/button/Button.ts b/packages/builders/src/components/button/Button.ts index 45827d54399f..bc68ecdb371f 100644 --- a/packages/builders/src/components/button/Button.ts +++ b/packages/builders/src/components/button/Button.ts @@ -10,6 +10,9 @@ import { } from '../Assertions'; import { UnsafeButtonComponent } from './UnsafeButton'; +/** + * Represents a validated button component + */ export class ButtonComponent extends UnsafeButtonComponent { public override setStyle(style: ButtonStyle) { return super.setStyle(buttonStyleValidator.parse(style)); @@ -36,7 +39,7 @@ export class ButtonComponent extends UnsafeButtonComponent { } public override toJSON(): APIButtonComponent { - validateRequiredButtonParameters(this.style, this.label, this.emoji, this.custom_id, this.url); + validateRequiredButtonParameters(this.style, this.label, this.emoji, this.customId, this.url); return super.toJSON(); } } diff --git a/packages/builders/src/components/button/UnsafeButton.ts b/packages/builders/src/components/button/UnsafeButton.ts index 182e72531f8b..149ed87d1cd5 100644 --- a/packages/builders/src/components/button/UnsafeButton.ts +++ b/packages/builders/src/components/button/UnsafeButton.ts @@ -3,33 +3,59 @@ import { ButtonStyle, type APIMessageComponentEmoji, type APIButtonComponent, + type APIButtonComponentWithURL, + type APIButtonComponentWithCustomId, } from 'discord-api-types/v9'; -import type { Component } from '../Component'; +import { Component } from '../Component'; -export class UnsafeButtonComponent implements Component { - public readonly type = ComponentType.Button as const; - public readonly style!: ButtonStyle; - public readonly label?: string; - public readonly emoji?: APIMessageComponentEmoji; - public readonly disabled?: boolean; - public readonly custom_id!: string; - public readonly url!: string; +/** + * Represents a non-validated button component + */ +export class UnsafeButtonComponent extends Component & { type: ComponentType.Button }> { + public constructor(data?: Partial) { + super({ type: ComponentType.Button, ...data }); + } - public constructor(data?: APIButtonComponent & { type?: ComponentType.Button }) { - /* eslint-disable @typescript-eslint/non-nullable-type-assertion-style */ - this.style = data?.style as ButtonStyle; - this.label = data?.label; - this.emoji = data?.emoji; - this.disabled = data?.disabled; + /** + * The style of this button + */ + public get style() { + return this.data.style; + } - // This if/else makes typescript happy - if (data?.style === ButtonStyle.Link) { - this.url = data.url; - } else { - this.custom_id = data?.custom_id as string; - } + /** + * The label of this button + */ + public get label() { + return this.data.label; + } + + /** + * The emoji used in this button + */ + public get emoji() { + return this.data.emoji; + } - /* eslint-enable @typescript-eslint/non-nullable-type-assertion-style */ + /** + * Whether this button is disabled + */ + public get disabled() { + return this.data.disabled; + } + + /** + * The custom id of this button (only defined on non-link buttons) + */ + public get customId(): string | undefined { + return (this.data as APIButtonComponentWithCustomId).custom_id; + } + + /** + * The URL of this button (only defined on link buttons) + */ + public get url(): string | undefined { + return (this.data as APIButtonComponentWithURL).url; } /** @@ -37,7 +63,7 @@ export class UnsafeButtonComponent implements Component { * @param style The style of the button */ public setStyle(style: ButtonStyle) { - Reflect.set(this, 'style', style); + this.data.style = style; return this; } @@ -46,16 +72,16 @@ export class UnsafeButtonComponent implements Component { * @param url The URL to open when this button is clicked */ public setURL(url: string) { - Reflect.set(this, 'url', url); + (this.data as APIButtonComponentWithURL).url = url; return this; } /** * Sets the custom Id for this button - * @param customId The custom ID to use for this button + * @param customId The custom id to use for this button */ public setCustomId(customId: string) { - Reflect.set(this, 'custom_id', customId); + (this.data as APIButtonComponentWithCustomId).custom_id = customId; return this; } @@ -64,7 +90,7 @@ export class UnsafeButtonComponent implements Component { * @param emoji The emoji to display on this button */ public setEmoji(emoji: APIMessageComponentEmoji) { - Reflect.set(this, 'emoji', emoji); + this.data.emoji = emoji; return this; } @@ -73,7 +99,7 @@ export class UnsafeButtonComponent implements Component { * @param disabled Whether or not to disable this button or not */ public setDisabled(disabled: boolean) { - Reflect.set(this, 'disabled', disabled); + this.data.disabled = disabled; return this; } @@ -82,13 +108,14 @@ export class UnsafeButtonComponent implements Component { * @param label The label to display on this button */ public setLabel(label: string) { - Reflect.set(this, 'label', label); + this.data.label = label; return this; } public toJSON(): APIButtonComponent { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions return { - ...this, - }; + ...this.data, + } as APIButtonComponent; } } diff --git a/packages/builders/src/components/selectMenu/SelectMenu.ts b/packages/builders/src/components/selectMenu/SelectMenu.ts index f5c45462f4cf..665157d29271 100644 --- a/packages/builders/src/components/selectMenu/SelectMenu.ts +++ b/packages/builders/src/components/selectMenu/SelectMenu.ts @@ -9,7 +9,7 @@ import { import { UnsafeSelectMenuComponent } from './UnsafeSelectMenu'; /** - * Represents a select menu component + * Represents a validated select menu component */ export class SelectMenuComponent extends UnsafeSelectMenuComponent { public override setPlaceholder(placeholder: string) { @@ -33,7 +33,7 @@ export class SelectMenuComponent extends UnsafeSelectMenuComponent { } public override toJSON(): APISelectMenuComponent { - validateRequiredSelectMenuParameters(this.options, this.custom_id); + validateRequiredSelectMenuParameters(this.options, this.customId); return super.toJSON(); } } diff --git a/packages/builders/src/components/selectMenu/SelectMenuOption.ts b/packages/builders/src/components/selectMenu/SelectMenuOption.ts index 7da5bddfe1b6..9f4e456ee715 100644 --- a/packages/builders/src/components/selectMenu/SelectMenuOption.ts +++ b/packages/builders/src/components/selectMenu/SelectMenuOption.ts @@ -8,7 +8,7 @@ import { import { UnsafeSelectMenuOption } from './UnsafeSelectMenuOption'; /** - * Represents an option within a select menu component + * Represents a validated option within a select menu component */ export class SelectMenuOption extends UnsafeSelectMenuOption { public override setDescription(description: string) { diff --git a/packages/builders/src/components/selectMenu/UnsafeSelectMenu.ts b/packages/builders/src/components/selectMenu/UnsafeSelectMenu.ts index 67b847b245e5..ee0ab23d67c5 100644 --- a/packages/builders/src/components/selectMenu/UnsafeSelectMenu.ts +++ b/packages/builders/src/components/selectMenu/UnsafeSelectMenu.ts @@ -1,28 +1,54 @@ import { ComponentType, type APISelectMenuComponent } from 'discord-api-types/v9'; -import type { Component } from '../Component'; -import { SelectMenuOption } from './SelectMenuOption'; +import { Component } from '../Component'; +import { UnsafeSelectMenuOption } from './UnsafeSelectMenuOption'; /** * Represents a non-validated select menu component */ -export class UnsafeSelectMenuComponent implements Component { - public readonly type = ComponentType.SelectMenu as const; - public readonly options: SelectMenuOption[]; - public readonly placeholder?: string; - public readonly min_values?: number; - public readonly max_values?: number; - public readonly custom_id!: string; - public readonly disabled?: boolean; +export class UnsafeSelectMenuComponent extends Component< + Partial> & { type: ComponentType.SelectMenu } +> { + public readonly options: UnsafeSelectMenuOption[]; - public constructor(data?: APISelectMenuComponent) { - this.options = data?.options.map((option) => new SelectMenuOption(option)) ?? []; - this.placeholder = data?.placeholder; - this.min_values = data?.min_values; - this.max_values = data?.max_values; - /* eslint-disable @typescript-eslint/non-nullable-type-assertion-style */ - this.custom_id = data?.custom_id as string; - /* eslint-enable @typescript-eslint/non-nullable-type-assertion-style */ - this.disabled = data?.disabled; + public constructor(data?: Partial) { + const { options, ...initData } = data ?? {}; + super({ type: ComponentType.SelectMenu, ...initData }); + this.options = options?.map((o) => new UnsafeSelectMenuOption(o)) ?? []; + } + + /** + * The placeholder for this select menu + */ + public get placeholder() { + return this.data.placeholder; + } + + /** + * The maximum amount of options that can be selected + */ + public get maxValues() { + return this.data.max_values; + } + + /** + * The minimum amount of options that must be selected + */ + public get minValues() { + return this.data.min_values; + } + + /** + * The custom id of this select menu + */ + public get customId() { + return this.data.custom_id; + } + + /** + * Whether this select menu is disabled + */ + public get disabled() { + return this.data.disabled; } /** @@ -30,34 +56,34 @@ export class UnsafeSelectMenuComponent implements Component { * @param placeholder The placeholder to use for this select menu */ public setPlaceholder(placeholder: string) { - Reflect.set(this, 'placeholder', placeholder); + this.data.placeholder = placeholder; return this; } /** - * Sets thes minimum values that must be selected in the select menu + * Sets the minimum values that must be selected in the select menu * @param minValues The minimum values that must be selected */ public setMinValues(minValues: number) { - Reflect.set(this, 'min_values', minValues); + this.data.min_values = minValues; return this; } /** - * Sets thes maximum values that must be selected in the select menu + * Sets the maximum values that must be selected in the select menu * @param minValues The maximum values that must be selected */ public setMaxValues(maxValues: number) { - Reflect.set(this, 'max_values', maxValues); + this.data.max_values = maxValues; return this; } /** * Sets the custom Id for this select menu - * @param customId The custom ID to use for this select menu + * @param customId The custom id to use for this select menu */ public setCustomId(customId: string) { - Reflect.set(this, 'custom_id', customId); + this.data.custom_id = customId; return this; } @@ -66,7 +92,7 @@ export class UnsafeSelectMenuComponent implements Component { * @param disabled Whether or not this select menu is disabled */ public setDisabled(disabled: boolean) { - Reflect.set(this, 'disabled', disabled); + this.data.disabled = disabled; return this; } @@ -75,7 +101,7 @@ export class UnsafeSelectMenuComponent implements Component { * @param options The options to add to this select menu * @returns */ - public addOptions(...options: SelectMenuOption[]) { + public addOptions(...options: UnsafeSelectMenuOption[]) { this.options.push(...options); return this; } @@ -84,15 +110,16 @@ export class UnsafeSelectMenuComponent implements Component { * Sets the options on this select menu * @param options The options to set on this select menu */ - public setOptions(...options: SelectMenuOption[]) { - Reflect.set(this, 'options', [...options]); + public setOptions(options: UnsafeSelectMenuOption[]) { + this.options.splice(0, this.options.length, ...options); return this; } public toJSON(): APISelectMenuComponent { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions return { - ...this, - options: this.options.map((option) => option.toJSON()), - }; + ...this.data, + options: this.options.map((o) => o.toJSON()), + } as APISelectMenuComponent; } } diff --git a/packages/builders/src/components/selectMenu/UnsafeSelectMenuOption.ts b/packages/builders/src/components/selectMenu/UnsafeSelectMenuOption.ts index a93b97321b80..94269300621b 100644 --- a/packages/builders/src/components/selectMenu/UnsafeSelectMenuOption.ts +++ b/packages/builders/src/components/selectMenu/UnsafeSelectMenuOption.ts @@ -4,20 +4,41 @@ import type { APIMessageComponentEmoji, APISelectMenuOption } from 'discord-api- * Represents a non-validated option within a select menu component */ export class UnsafeSelectMenuOption { - public readonly label!: string; - public readonly value!: string; - public readonly description?: string; - public readonly emoji?: APIMessageComponentEmoji; - public readonly default?: boolean; + public constructor(protected data: Partial = {}) {} - public constructor(data?: APISelectMenuOption) { - /* eslint-disable @typescript-eslint/non-nullable-type-assertion-style */ - this.label = data?.label as string; - this.value = data?.value as string; - /* eslint-enable @typescript-eslint/non-nullable-type-assertion-style */ - this.description = data?.description; - this.emoji = data?.emoji; - this.default = data?.default; + /** + * The label for this option + */ + public get label() { + return this.data.label; + } + + /** + * The value for this option + */ + public get value() { + return this.data.value; + } + + /** + * The description for this option + */ + public get description() { + return this.data.description; + } + + /** + * The emoji for this option + */ + public get emoji() { + return this.data.emoji; + } + + /** + * Whether this option is selected by default + */ + public get default() { + return this.data.default; } /** @@ -25,7 +46,7 @@ export class UnsafeSelectMenuOption { * @param label The label to show on this option */ public setLabel(label: string) { - Reflect.set(this, 'label', label); + this.data.label = label; return this; } @@ -34,7 +55,7 @@ export class UnsafeSelectMenuOption { * @param value The value of this option */ public setValue(value: string) { - Reflect.set(this, 'value', value); + this.data.value = value; return this; } @@ -43,16 +64,16 @@ export class UnsafeSelectMenuOption { * @param description The description of this option */ public setDescription(description: string) { - Reflect.set(this, 'description', description); + this.data.description = description; return this; } /** * Sets whether this option is selected by default - * @param isDefault Whether or not this option is selected by default + * @param isDefault Whether this option is selected by default */ public setDefault(isDefault: boolean) { - Reflect.set(this, 'default', isDefault); + this.data.default = isDefault; return this; } @@ -61,13 +82,14 @@ export class UnsafeSelectMenuOption { * @param emoji The emoji to display on this button */ public setEmoji(emoji: APIMessageComponentEmoji) { - Reflect.set(this, 'emoji', emoji); + this.data.emoji = emoji; return this; } public toJSON(): APISelectMenuOption { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions return { - ...this, - }; + ...this.data, + } as APISelectMenuOption; } } diff --git a/packages/builders/src/messages/embed/Assertions.ts b/packages/builders/src/messages/embed/Assertions.ts index 2d0270978b1d..8dade68f9fa0 100644 --- a/packages/builders/src/messages/embed/Assertions.ts +++ b/packages/builders/src/messages/embed/Assertions.ts @@ -17,8 +17,8 @@ export const embedFieldsArrayPredicate = embedFieldPredicate.array(); export const fieldLengthPredicate = z.number().lte(25); -export function validateFieldLength(fields: APIEmbedField[], amountAdding: number): void { - fieldLengthPredicate.parse(fields.length + amountAdding); +export function validateFieldLength(amountAdding: number, fields?: APIEmbedField[]): void { + fieldLengthPredicate.parse((fields?.length ?? 0) + amountAdding); } export const authorNamePredicate = fieldNamePredicate.nullable(); diff --git a/packages/builders/src/messages/embed/Embed.ts b/packages/builders/src/messages/embed/Embed.ts index 3e0096db5c14..c167988d2723 100644 --- a/packages/builders/src/messages/embed/Embed.ts +++ b/packages/builders/src/messages/embed/Embed.ts @@ -13,15 +13,15 @@ import { urlPredicate, validateFieldLength, } from './Assertions'; -import { AuthorOptions, FooterOptions, UnsafeEmbed } from './UnsafeEmbed'; +import { EmbedAuthorOptions, EmbedFooterOptions, UnsafeEmbed } from './UnsafeEmbed'; /** - * Represents an embed in a message (image/video preview, rich embed, etc.) + * Represents a validated embed in a message (image/video preview, rich embed, etc.) */ export class Embed extends UnsafeEmbed { public override addFields(...fields: APIEmbedField[]): this { // Ensure adding these fields won't exceed the 25 field limit - validateFieldLength(this.fields, fields.length); + validateFieldLength(fields.length, this.fields); // Data assertions return super.addFields(...embedFieldsArrayPredicate.parse(fields)); @@ -29,13 +29,13 @@ export class Embed extends UnsafeEmbed { public override spliceFields(index: number, deleteCount: number, ...fields: APIEmbedField[]): this { // Ensure adding these fields won't exceed the 25 field limit - validateFieldLength(this.fields, fields.length - deleteCount); + validateFieldLength(fields.length - deleteCount, this.fields); // Data assertions return super.spliceFields(index, deleteCount, ...embedFieldsArrayPredicate.parse(fields)); } - public override setAuthor(options: AuthorOptions | null): this { + public override setAuthor(options: EmbedAuthorOptions | null): this { if (options === null) { return super.setAuthor(null); } @@ -58,7 +58,7 @@ export class Embed extends UnsafeEmbed { return super.setDescription(descriptionPredicate.parse(description)); } - public override setFooter(options: FooterOptions | null): this { + public override setFooter(options: EmbedFooterOptions | null): this { if (options === null) { return super.setFooter(null); } diff --git a/packages/builders/src/messages/embed/UnsafeEmbed.ts b/packages/builders/src/messages/embed/UnsafeEmbed.ts index b286e6168d0b..a08d6af65552 100644 --- a/packages/builders/src/messages/embed/UnsafeEmbed.ts +++ b/packages/builders/src/messages/embed/UnsafeEmbed.ts @@ -4,98 +4,151 @@ import type { APIEmbedField, APIEmbedFooter, APIEmbedImage, - APIEmbedProvider, - APIEmbedThumbnail, APIEmbedVideo, } from 'discord-api-types/v9'; -import type { JSONEncodable } from '../../util/jsonEncodable'; -export interface AuthorOptions { - name: string; - url?: string; +export interface IconData { + /** + * The URL of the icon + */ iconURL?: string; + /** + * The proxy URL of the icon + */ + proxyIconURL?: string; } -export interface FooterOptions { - text: string; - iconURL?: string; +export type EmbedAuthorData = Omit & IconData; + +export type EmbedAuthorOptions = Omit; + +export type EmbedFooterData = Omit & IconData; + +export type EmbedFooterOptions = Omit; + +export interface EmbedImageData extends Omit { + /** + * The proxy URL for the image + */ + proxyURL?: string; } -export class UnsafeEmbed implements APIEmbed, JSONEncodable { +/** + * Represents a non-validated embed in a message (image/video preview, rich embed, etc.) + */ +export class UnsafeEmbed { + protected data: APIEmbed; + + public constructor(data: APIEmbed = {}) { + this.data = { ...data }; + if (data.timestamp) this.data.timestamp = new Date(data.timestamp).toISOString(); + } + /** * An array of fields of this embed */ - public readonly fields: APIEmbedField[]; + public get fields() { + return this.data.fields; + } /** * The embed title */ - public readonly title?: string; + public get title() { + return this.data.title; + } /** * The embed description */ - public readonly description?: string; + public get description() { + return this.data.description; + } /** - * The embed url + * The embed URL */ - public readonly url?: string; + public get url() { + return this.data.url; + } /** * The embed color */ - public readonly color?: number; + public get color() { + return this.data.color; + } /** - * The timestamp of the embed in the ISO format + * The timestamp of the embed in an ISO 8601 format */ - public readonly timestamp?: string; + public get timestamp() { + return this.data.timestamp; + } /** * The embed thumbnail data */ - public readonly thumbnail?: APIEmbedThumbnail; + public get thumbnail(): EmbedImageData | undefined { + if (!this.data.thumbnail) return undefined; + return { + url: this.data.thumbnail.url, + proxyURL: this.data.thumbnail.proxy_url, + height: this.data.thumbnail.height, + width: this.data.thumbnail.width, + }; + } /** * The embed image data */ - public readonly image?: APIEmbedImage; + public get image(): EmbedImageData | undefined { + if (!this.data.image) return undefined; + return { + url: this.data.image.url, + proxyURL: this.data.image.proxy_url, + height: this.data.image.height, + width: this.data.image.width, + }; + } /** * Received video data */ - public readonly video?: APIEmbedVideo; + public get video(): APIEmbedVideo | undefined { + return this.data.video; + } /** * The embed author data */ - public readonly author?: APIEmbedAuthor; + public get author(): EmbedAuthorData | undefined { + if (!this.data.author) return undefined; + return { + name: this.data.author.name, + url: this.data.author.url, + iconURL: this.data.author.icon_url, + proxyIconURL: this.data.author.proxy_icon_url, + }; + } /** * Received data about the embed provider */ - public readonly provider?: APIEmbedProvider; + public get provider() { + return this.data.provider; + } /** * The embed footer data */ - public readonly footer?: APIEmbedFooter; - - public constructor(data: APIEmbed = {}) { - this.title = data.title; - this.description = data.description; - this.url = data.url; - this.color = data.color; - this.thumbnail = data.thumbnail; - this.image = data.image; - this.video = data.video; - this.author = data.author; - this.provider = data.provider; - this.footer = data.footer; - this.fields = data.fields ?? []; - - if (data.timestamp) this.timestamp = new Date(data.timestamp).toISOString(); + public get footer(): EmbedFooterData | undefined { + if (!this.data.footer) return undefined; + return { + text: this.data.footer.text, + iconURL: this.data.footer.icon_url, + proxyIconURL: this.data.footer.proxy_icon_url, + }; } /** @@ -103,11 +156,11 @@ export class UnsafeEmbed implements APIEmbed, JSONEncodable { */ public get length(): number { return ( - (this.title?.length ?? 0) + - (this.description?.length ?? 0) + - this.fields.reduce((prev, curr) => prev + curr.name.length + curr.value.length, 0) + - (this.footer?.text.length ?? 0) + - (this.author?.name.length ?? 0) + (this.data.title?.length ?? 0) + + (this.data.description?.length ?? 0) + + (this.data.fields?.reduce((prev, curr) => prev + curr.name.length + curr.value.length, 0) ?? 0) + + (this.data.footer?.text.length ?? 0) + + (this.data.author?.name.length ?? 0) ); } @@ -126,7 +179,9 @@ export class UnsafeEmbed implements APIEmbed, JSONEncodable { * @param fields The fields to add */ public addFields(...fields: APIEmbedField[]): this { - this.fields.push(...UnsafeEmbed.normalizeFields(...fields)); + fields = UnsafeEmbed.normalizeFields(...fields); + if (this.data.fields) this.data.fields.push(...fields); + else this.data.fields = fields; return this; } @@ -138,7 +193,9 @@ export class UnsafeEmbed implements APIEmbed, JSONEncodable { * @param fields The replacing field objects */ public spliceFields(index: number, deleteCount: number, ...fields: APIEmbedField[]): this { - this.fields.splice(index, deleteCount, ...UnsafeEmbed.normalizeFields(...fields)); + fields = UnsafeEmbed.normalizeFields(...fields); + if (this.data.fields) this.data.fields.splice(index, deleteCount, ...fields); + else this.data.fields = fields; return this; } @@ -147,7 +204,7 @@ export class UnsafeEmbed implements APIEmbed, JSONEncodable { * @param fields The fields to set */ public setFields(...fields: APIEmbedField[]) { - this.spliceFields(0, this.fields.length, ...fields); + this.spliceFields(0, this.fields?.length ?? 0, ...fields); return this; } @@ -156,13 +213,13 @@ export class UnsafeEmbed implements APIEmbed, JSONEncodable { * * @param options The options for the author */ - public setAuthor(options: AuthorOptions | null): this { + public setAuthor(options: EmbedAuthorOptions | null): this { if (options === null) { - Reflect.set(this, 'author', undefined); + this.data.author = undefined; return this; } - Reflect.set(this, 'author', { name: options.name, url: options.url, icon_url: options.iconURL }); + this.data.author = { name: options.name, url: options.url, icon_url: options.iconURL }; return this; } @@ -172,7 +229,7 @@ export class UnsafeEmbed implements APIEmbed, JSONEncodable { * @param color The color of the embed */ public setColor(color: number | null): this { - Reflect.set(this, 'color', color ?? undefined); + this.data.color = color ?? undefined; return this; } @@ -182,7 +239,7 @@ export class UnsafeEmbed implements APIEmbed, JSONEncodable { * @param description The description */ public setDescription(description: string | null): this { - Reflect.set(this, 'description', description ?? undefined); + this.data.description = description ?? undefined; return this; } @@ -191,13 +248,13 @@ export class UnsafeEmbed implements APIEmbed, JSONEncodable { * * @param options The options for the footer */ - public setFooter(options: FooterOptions | null): this { + public setFooter(options: EmbedFooterOptions | null): this { if (options === null) { - Reflect.set(this, 'footer', undefined); + this.data.footer = undefined; return this; } - Reflect.set(this, 'footer', { text: options.text, icon_url: options.iconURL }); + this.data.footer = { text: options.text, icon_url: options.iconURL }; return this; } @@ -207,7 +264,7 @@ export class UnsafeEmbed implements APIEmbed, JSONEncodable { * @param url The URL of the image */ public setImage(url: string | null): this { - Reflect.set(this, 'image', url ? { url } : undefined); + this.data.image = url ? { url } : undefined; return this; } @@ -217,7 +274,7 @@ export class UnsafeEmbed implements APIEmbed, JSONEncodable { * @param url The URL of the thumbnail */ public setThumbnail(url: string | null): this { - Reflect.set(this, 'thumbnail', url ? { url } : undefined); + this.data.thumbnail = url ? { url } : undefined; return this; } @@ -227,7 +284,7 @@ export class UnsafeEmbed implements APIEmbed, JSONEncodable { * @param timestamp The timestamp or date */ public setTimestamp(timestamp: number | Date | null = Date.now()): this { - Reflect.set(this, 'timestamp', timestamp ? new Date(timestamp).toISOString() : undefined); + this.data.timestamp = timestamp ? new Date(timestamp).toISOString() : undefined; return this; } @@ -237,7 +294,7 @@ export class UnsafeEmbed implements APIEmbed, JSONEncodable { * @param title The title */ public setTitle(title: string | null): this { - Reflect.set(this, 'title', title ?? undefined); + this.data.title = title ?? undefined; return this; } @@ -247,7 +304,7 @@ export class UnsafeEmbed implements APIEmbed, JSONEncodable { * @param url The URL */ public setURL(url: string | null): this { - Reflect.set(this, 'url', url ?? undefined); + this.data.url = url ?? undefined; return this; } @@ -255,7 +312,7 @@ export class UnsafeEmbed implements APIEmbed, JSONEncodable { * Transforms the embed to a plain object */ public toJSON(): APIEmbed { - return { ...this }; + return { ...this.data }; } /** diff --git a/packages/discord.js/typings/index.test-d.ts b/packages/discord.js/typings/index.test-d.ts index 814adac9de32..9abeb006c583 100644 --- a/packages/discord.js/typings/index.test-d.ts +++ b/packages/discord.js/typings/index.test-d.ts @@ -724,7 +724,7 @@ client.on('interactionCreate', async interaction => { const button = new ButtonComponent(); - const actionRow = new ActionRow({ type: ComponentType.ActionRow, components: [button] }); + const actionRow = new ActionRow({ type: ComponentType.ActionRow, components: [button.toJSON()] }); await interaction.reply({ content: 'Hi!', components: [actionRow] });