diff --git a/packages/builders/__tests__/components/actionRow.test.ts b/packages/builders/__tests__/components/actionRow.test.ts index 6ec31dfc9..9bb74328e 100644 --- a/packages/builders/__tests__/components/actionRow.test.ts +++ b/packages/builders/__tests__/components/actionRow.test.ts @@ -45,6 +45,8 @@ const rowWithSelectMenuData: APIActionRowComponent describe('Action Row Components', () => { describe('Assertion Tests', () => { test('GIVEN valid components THEN do not throw', () => { + expect(() => new ActionRowBuilder().addComponents(new ButtonBuilder())).not.toThrowError(); + expect(() => new ActionRowBuilder().setComponents(new ButtonBuilder())).not.toThrowError(); expect(() => new ActionRowBuilder().addComponents([new ButtonBuilder()])).not.toThrowError(); expect(() => new ActionRowBuilder().setComponents([new ButtonBuilder()])).not.toThrowError(); }); @@ -131,11 +133,17 @@ describe('Action Row Components', () => { .setCustomId('1234') .setMaxValues(10) .setMinValues(12) + .setOptions( + new SelectMenuOptionBuilder().setLabel('one').setValue('one'), + new SelectMenuOptionBuilder().setLabel('two').setValue('two'), + ) .setOptions([ new SelectMenuOptionBuilder().setLabel('one').setValue('one'), new SelectMenuOptionBuilder().setLabel('two').setValue('two'), ]); + expect(new ActionRowBuilder().addComponents(button).toJSON()).toEqual(rowWithButtonData); + expect(new ActionRowBuilder().addComponents(selectMenu).toJSON()).toEqual(rowWithSelectMenuData); expect(new ActionRowBuilder().addComponents([button]).toJSON()).toEqual(rowWithButtonData); expect(new ActionRowBuilder().addComponents([selectMenu]).toJSON()).toEqual(rowWithSelectMenuData); }); diff --git a/packages/builders/__tests__/components/selectMenu.test.ts b/packages/builders/__tests__/components/selectMenu.test.ts index 668207ed7..ed95ea4d0 100644 --- a/packages/builders/__tests__/components/selectMenu.test.ts +++ b/packages/builders/__tests__/components/selectMenu.test.ts @@ -44,12 +44,15 @@ describe('Select Menu Components', () => { .setDefault(true) .setEmoji({ name: 'test' }) .setDescription('description'); + expect(() => selectMenu().addOptions(option)).not.toThrowError(); + expect(() => selectMenu().setOptions(option)).not.toThrowError(); + expect(() => selectMenu().setOptions({ label: 'test', value: 'test' })).not.toThrowError(); expect(() => selectMenu().addOptions([option])).not.toThrowError(); expect(() => selectMenu().setOptions([option])).not.toThrowError(); expect(() => selectMenu().setOptions([{ label: 'test', value: 'test' }])).not.toThrowError(); expect(() => - selectMenu().addOptions([ - { + selectMenu() + .addOptions({ label: 'test', value: 'test', emoji: { @@ -57,14 +60,31 @@ describe('Select Menu Components', () => { name: 'test', animated: true, }, - }, - ]), + }) + .addOptions([ + { + label: 'test', + value: 'test', + emoji: { + id: '123', + name: 'test', + animated: true, + }, + }, + ]), ).not.toThrowError(); const options = new Array(25).fill({ label: 'test', value: 'test' }); + expect(() => selectMenu().addOptions(...options)).not.toThrowError(); + expect(() => selectMenu().setOptions(...options)).not.toThrowError(); expect(() => selectMenu().addOptions(options)).not.toThrowError(); expect(() => selectMenu().setOptions(options)).not.toThrowError(); + expect(() => + selectMenu() + .addOptions({ label: 'test', value: 'test' }) + .addOptions(...new Array(24).fill({ label: 'test', value: 'test' })), + ).not.toThrowError(); expect(() => selectMenu() .addOptions([{ label: 'test', value: 'test' }]) @@ -80,6 +100,17 @@ describe('Select Menu Components', () => { expect(() => selectMenu().setDisabled(0)).toThrowError(); expect(() => selectMenu().setPlaceholder(longStr)).toThrowError(); // @ts-expect-error + expect(() => selectMenu().addOptions({ label: 'test' })).toThrowError(); + expect(() => selectMenu().addOptions({ label: longStr, value: 'test' })).toThrowError(); + expect(() => selectMenu().addOptions({ value: longStr, label: 'test' })).toThrowError(); + expect(() => selectMenu().addOptions({ label: 'test', value: 'test', description: longStr })).toThrowError(); + // @ts-expect-error + expect(() => selectMenu().addOptions({ label: 'test', value: 'test', default: 100 })).toThrowError(); + // @ts-expect-error + expect(() => selectMenu().addOptions({ value: 'test' })).toThrowError(); + // @ts-expect-error + expect(() => selectMenu().addOptions({ default: true })).toThrowError(); + // @ts-expect-error expect(() => selectMenu().addOptions([{ label: 'test' }])).toThrowError(); expect(() => selectMenu().addOptions([{ label: longStr, value: 'test' }])).toThrowError(); expect(() => selectMenu().addOptions([{ value: longStr, label: 'test' }])).toThrowError(); @@ -92,8 +123,14 @@ describe('Select Menu Components', () => { expect(() => selectMenu().addOptions([{ default: true }])).toThrowError(); const tooManyOptions = new Array(26).fill({ label: 'test', value: 'test' }); + expect(() => selectMenu().setOptions(...tooManyOptions)).toThrowError(); expect(() => selectMenu().setOptions(tooManyOptions)).toThrowError(); + expect(() => + selectMenu() + .addOptions({ label: 'test', value: 'test' }) + .addOptions(...tooManyOptions), + ).toThrowError(); expect(() => selectMenu() .addOptions([{ label: 'test', value: 'test' }]) @@ -113,6 +150,11 @@ describe('Select Menu Components', () => { }); test('GIVEN valid JSON input THEN valid JSON history is correct', () => { + expect( + new SelectMenuBuilder(selectMenuDataWithoutOptions) + .addOptions(new SelectMenuOptionBuilder(selectMenuOptionData)) + .toJSON(), + ).toEqual(selectMenuData); expect( new SelectMenuBuilder(selectMenuDataWithoutOptions) .addOptions([new SelectMenuOptionBuilder(selectMenuOptionData)]) diff --git a/packages/builders/__tests__/interactions/modal.test.ts b/packages/builders/__tests__/interactions/modal.test.ts index 4484dd1b4..6c4eb9904 100644 --- a/packages/builders/__tests__/interactions/modal.test.ts +++ b/packages/builders/__tests__/interactions/modal.test.ts @@ -49,7 +49,11 @@ describe('Modals', () => { test('GIVEN valid fields THEN builder does not throw', () => { expect(() => - modal().setTitle('test').setCustomId('foobar').setComponents([new ActionRowBuilder()]), + modal() + .setTitle('test') + .setCustomId('foobar') + .setComponents(new ActionRowBuilder()) + .addComponents([new ActionRowBuilder()]), ).not.toThrowError(); }); @@ -75,6 +79,17 @@ describe('Modals', () => { }, ], }, + { + type: ComponentType.ActionRow, + components: [ + { + type: ComponentType.TextInput, + label: 'label', + style: TextInputStyle.Paragraph, + custom_id: 'custom id', + }, + ], + }, ], }; @@ -84,10 +99,15 @@ describe('Modals', () => { modal() .setTitle(modalData.title) .setCustomId('custom id') - .setComponents([ - new ActionRowBuilder().addComponents([ + .setComponents( + new ActionRowBuilder().addComponents( new TextInputBuilder().setCustomId('custom id').setLabel('label').setStyle(TextInputStyle.Paragraph), - ]), + ), + ) + .addComponents([ + new ActionRowBuilder().addComponents( + new TextInputBuilder().setCustomId('custom id').setLabel('label').setStyle(TextInputStyle.Paragraph), + ), ]) .toJSON(), ).toEqual(modalData); diff --git a/packages/builders/__tests__/messages/embed.test.ts b/packages/builders/__tests__/messages/embed.test.ts index 3d2ade85a..f7d234083 100644 --- a/packages/builders/__tests__/messages/embed.test.ts +++ b/packages/builders/__tests__/messages/embed.test.ts @@ -323,19 +323,20 @@ describe('Embed', () => { test('GIVEN an embed using Embed#addFields THEN returns valid toJSON data', () => { const embed = new EmbedBuilder(); + embed.addFields({ name: 'foo', value: 'bar' }); embed.addFields([{ name: 'foo', value: 'bar' }]); expect(embed.toJSON()).toStrictEqual({ - fields: [{ name: 'foo', value: 'bar' }], + fields: [ + { name: 'foo', value: 'bar' }, + { name: 'foo', value: 'bar' }, + ], }); }); test('GIVEN an embed using Embed#spliceFields THEN returns valid toJSON data', () => { const embed = new EmbedBuilder(); - embed.addFields([ - { name: 'foo', value: 'bar' }, - { name: 'foo', value: 'baz' }, - ]); + embed.addFields({ name: 'foo', value: 'bar' }, { name: 'foo', value: 'baz' }); expect(embed.spliceFields(0, 1).toJSON()).toStrictEqual({ fields: [{ name: 'foo', value: 'baz' }], @@ -344,7 +345,7 @@ describe('Embed', () => { test('GIVEN an embed using Embed#spliceFields THEN returns valid toJSON data 2', () => { const embed = new EmbedBuilder(); - embed.addFields(Array.from({ length: 23 }, () => ({ name: 'foo', value: 'bar' }))); + embed.addFields(...Array.from({ length: 23 }, () => ({ name: 'foo', value: 'bar' }))); expect(() => embed.spliceFields(0, 3, ...Array.from({ length: 5 }, () => ({ name: 'foo', value: 'bar' }))), @@ -353,7 +354,7 @@ describe('Embed', () => { test('GIVEN an embed using Embed#spliceFields that adds additional fields resulting in fields > 25 THEN throws error', () => { const embed = new EmbedBuilder(); - embed.addFields(Array.from({ length: 23 }, () => ({ name: 'foo', value: 'bar' }))); + embed.addFields(...Array.from({ length: 23 }, () => ({ name: 'foo', value: 'bar' }))); expect(() => embed.spliceFields(0, 3, ...Array.from({ length: 8 }, () => ({ name: 'foo', value: 'bar' }))), @@ -363,6 +364,9 @@ describe('Embed', () => { test('GIVEN an embed using Embed#setFields THEN returns valid toJSON data', () => { const embed = new EmbedBuilder(); + expect(() => + embed.setFields(...Array.from({ length: 25 }, () => ({ name: 'foo', value: 'bar' }))), + ).not.toThrowError(); expect(() => embed.setFields(Array.from({ length: 25 }, () => ({ name: 'foo', value: 'bar' }))), ).not.toThrowError(); @@ -371,6 +375,9 @@ describe('Embed', () => { test('GIVEN an embed using Embed#setFields that sets more than 25 fields THEN throws error', () => { const embed = new EmbedBuilder(); + expect(() => + embed.setFields(...Array.from({ length: 26 }, () => ({ name: 'foo', value: 'bar' }))), + ).toThrowError(); expect(() => embed.setFields(Array.from({ length: 26 }, () => ({ name: 'foo', value: 'bar' })))).toThrowError(); }); @@ -378,7 +385,9 @@ describe('Embed', () => { test('1', () => { const embed = new EmbedBuilder(); - expect(() => embed.addFields(Array.from({ length: 26 }, () => ({ name: 'foo', value: 'bar' })))).toThrowError(); + expect(() => + embed.addFields(...Array.from({ length: 26 }, () => ({ name: 'foo', value: 'bar' }))), + ).toThrowError(); }); }); @@ -386,7 +395,7 @@ describe('Embed', () => { test('2', () => { const embed = new EmbedBuilder(); - expect(() => embed.addFields([{ name: '', value: 'bar' }])).toThrowError(); + expect(() => embed.addFields({ name: '', value: 'bar' })).toThrowError(); }); }); @@ -394,7 +403,7 @@ describe('Embed', () => { test('3', () => { const embed = new EmbedBuilder(); - expect(() => embed.addFields([{ name: 'a'.repeat(257), value: 'bar' }])).toThrowError(); + expect(() => embed.addFields({ name: 'a'.repeat(257), value: 'bar' })).toThrowError(); }); }); @@ -402,7 +411,7 @@ describe('Embed', () => { test('4', () => { const embed = new EmbedBuilder(); - expect(() => embed.addFields([{ name: '', value: 'a'.repeat(1025) }])).toThrowError(); + expect(() => embed.addFields({ name: '', value: 'a'.repeat(1025) })).toThrowError(); }); }); }); diff --git a/packages/builders/src/components/ActionRow.ts b/packages/builders/src/components/ActionRow.ts index d97f0ad57..b04636c8c 100644 --- a/packages/builders/src/components/ActionRow.ts +++ b/packages/builders/src/components/ActionRow.ts @@ -8,6 +8,7 @@ import { import { ComponentBuilder } from './Component'; import { createComponentBuilder } from './Components'; import type { ButtonBuilder, SelectMenuBuilder, TextInputBuilder } from '..'; +import { normalizeArray, type RestOrArray } from '../util/normalizeArray'; export type MessageComponentBuilder = | MessageActionRowComponentBuilder @@ -38,8 +39,8 @@ export class ActionRowBuilder extends ComponentBu * @param components The components to add to this action row. * @returns */ - public addComponents(components: T[]) { - this.components.push(...components); + public addComponents(...components: RestOrArray) { + this.components.push(...normalizeArray(components)); return this; } @@ -47,8 +48,8 @@ export class ActionRowBuilder extends ComponentBu * Sets the components in this action row * @param components The components to set this row to */ - public setComponents(components: T[]) { - this.components.splice(0, this.components.length, ...components); + public setComponents(...components: RestOrArray) { + this.components.splice(0, this.components.length, ...normalizeArray(components)); return this; } diff --git a/packages/builders/src/components/selectMenu/SelectMenu.ts b/packages/builders/src/components/selectMenu/SelectMenu.ts index c740a3c22..ce7b16157 100644 --- a/packages/builders/src/components/selectMenu/SelectMenu.ts +++ b/packages/builders/src/components/selectMenu/SelectMenu.ts @@ -1,6 +1,7 @@ import type { APISelectMenuComponent, APISelectMenuOption } from 'discord-api-types/v10'; import { UnsafeSelectMenuBuilder } from './UnsafeSelectMenu'; import { UnsafeSelectMenuOptionBuilder } from './UnsafeSelectMenuOption'; +import { normalizeArray, type RestOrArray } from '../../util/normalizeArray'; import { customIdValidator, disabledValidator, @@ -35,7 +36,8 @@ export class SelectMenuBuilder extends UnsafeSelectMenuBuilder { return super.setDisabled(disabledValidator.parse(disabled)); } - public override addOptions(options: (UnsafeSelectMenuOptionBuilder | APISelectMenuOption)[]) { + public override addOptions(...options: RestOrArray) { + options = normalizeArray(options); optionsLengthValidator.parse(this.options.length + options.length); this.options.push( ...options.map((option) => @@ -47,7 +49,8 @@ export class SelectMenuBuilder extends UnsafeSelectMenuBuilder { return this; } - public override setOptions(options: (UnsafeSelectMenuOptionBuilder | APISelectMenuOption)[]) { + public override setOptions(...options: RestOrArray) { + options = normalizeArray(options); optionsLengthValidator.parse(options.length); this.options.splice( 0, diff --git a/packages/builders/src/components/selectMenu/UnsafeSelectMenu.ts b/packages/builders/src/components/selectMenu/UnsafeSelectMenu.ts index c019a3898..65e645749 100644 --- a/packages/builders/src/components/selectMenu/UnsafeSelectMenu.ts +++ b/packages/builders/src/components/selectMenu/UnsafeSelectMenu.ts @@ -1,5 +1,6 @@ import { APISelectMenuOption, ComponentType, type APISelectMenuComponent } from 'discord-api-types/v10'; import { UnsafeSelectMenuOptionBuilder } from './UnsafeSelectMenuOption'; +import { normalizeArray, type RestOrArray } from '../../util/normalizeArray'; import { ComponentBuilder } from '../Component'; /** @@ -67,9 +68,9 @@ export class UnsafeSelectMenuBuilder extends ComponentBuilder) { this.options.push( - ...options.map((option) => + ...normalizeArray(options).map((option) => option instanceof UnsafeSelectMenuOptionBuilder ? option : new UnsafeSelectMenuOptionBuilder(option), ), ); @@ -80,11 +81,11 @@ export class UnsafeSelectMenuBuilder extends ComponentBuilder) { this.options.splice( 0, this.options.length, - ...options.map((option) => + ...normalizeArray(options).map((option) => option instanceof UnsafeSelectMenuOptionBuilder ? option : new UnsafeSelectMenuOptionBuilder(option), ), ); diff --git a/packages/builders/src/index.ts b/packages/builders/src/index.ts index d52311855..f3e30d6ae 100644 --- a/packages/builders/src/index.ts +++ b/packages/builders/src/index.ts @@ -45,3 +45,4 @@ export * from './interactions/contextMenuCommands/ContextMenuCommandBuilder'; export * from './util/jsonEncodable'; export * from './util/equatable'; export * from './util/componentUtil'; +export * from './util/normalizeArray'; diff --git a/packages/builders/src/interactions/modals/UnsafeModal.ts b/packages/builders/src/interactions/modals/UnsafeModal.ts index 0c139b784..e69e7edc3 100644 --- a/packages/builders/src/interactions/modals/UnsafeModal.ts +++ b/packages/builders/src/interactions/modals/UnsafeModal.ts @@ -4,6 +4,7 @@ import type { APIModalInteractionResponseCallbackData, } from 'discord-api-types/v10'; import { ActionRowBuilder, createComponentBuilder, JSONEncodable, ModalActionRowComponentBuilder } from '../../index'; +import { normalizeArray, type RestOrArray } from '../../util/normalizeArray'; export class UnsafeModalBuilder implements JSONEncodable { public readonly data: Partial; @@ -38,13 +39,12 @@ export class UnsafeModalBuilder implements JSONEncodable - | APIActionRowComponent - )[], + ...components: RestOrArray< + ActionRowBuilder | APIActionRowComponent + > ) { this.components.push( - ...components.map((component) => + ...normalizeArray(components).map((component) => component instanceof ActionRowBuilder ? component : new ActionRowBuilder(component), @@ -57,8 +57,8 @@ export class UnsafeModalBuilder implements JSONEncodable[]) { - this.components.splice(0, this.components.length, ...components); + public setComponents(...components: RestOrArray>) { + this.components.splice(0, this.components.length, ...normalizeArray(components)); return this; } diff --git a/packages/builders/src/messages/embed/Embed.ts b/packages/builders/src/messages/embed/Embed.ts index 7747199ab..da616e1bb 100644 --- a/packages/builders/src/messages/embed/Embed.ts +++ b/packages/builders/src/messages/embed/Embed.ts @@ -12,17 +12,19 @@ import { validateFieldLength, } from './Assertions'; import { EmbedAuthorOptions, EmbedFooterOptions, RGBTuple, UnsafeEmbedBuilder } from './UnsafeEmbed'; +import { normalizeArray, type RestOrArray } from '../../util/normalizeArray'; /** * Represents a validated embed in a message (image/video preview, rich embed, etc.) */ export class EmbedBuilder extends UnsafeEmbedBuilder { - public override addFields(fields: APIEmbedField[]): this { + public override addFields(...fields: RestOrArray): this { + fields = normalizeArray(fields); // Ensure adding these fields won't exceed the 25 field limit validateFieldLength(fields.length, this.data.fields); // Data assertions - return super.addFields(embedFieldsArrayPredicate.parse(fields)); + return super.addFields(...embedFieldsArrayPredicate.parse(fields)); } public override spliceFields(index: number, deleteCount: number, ...fields: APIEmbedField[]): this { diff --git a/packages/builders/src/messages/embed/UnsafeEmbed.ts b/packages/builders/src/messages/embed/UnsafeEmbed.ts index 6b381698b..1e68d0f9b 100644 --- a/packages/builders/src/messages/embed/UnsafeEmbed.ts +++ b/packages/builders/src/messages/embed/UnsafeEmbed.ts @@ -1,4 +1,5 @@ import type { APIEmbed, APIEmbedAuthor, APIEmbedField, APIEmbedFooter, APIEmbedImage } from 'discord-api-types/v10'; +import { normalizeArray, type RestOrArray } from '../../util/normalizeArray'; export type RGBTuple = [red: number, green: number, blue: number]; @@ -44,7 +45,8 @@ export class UnsafeEmbedBuilder { * * @param fields The fields to add */ - public addFields(fields: APIEmbedField[]): this { + public addFields(...fields: RestOrArray): this { + fields = normalizeArray(fields); if (this.data.fields) this.data.fields.push(...fields); else this.data.fields = fields; return this; @@ -67,8 +69,8 @@ export class UnsafeEmbedBuilder { * Sets the embed's fields (max 25). * @param fields The fields to set */ - public setFields(fields: APIEmbedField[]) { - this.spliceFields(0, this.data.fields?.length ?? 0, ...fields); + public setFields(...fields: RestOrArray) { + this.spliceFields(0, this.data.fields?.length ?? 0, ...normalizeArray(fields)); return this; } diff --git a/packages/builders/src/util/normalizeArray.ts b/packages/builders/src/util/normalizeArray.ts new file mode 100644 index 000000000..2fda6fbdf --- /dev/null +++ b/packages/builders/src/util/normalizeArray.ts @@ -0,0 +1,6 @@ +export function normalizeArray(arr: RestOrArray): T[] { + if (Array.isArray(arr[0])) return arr[0]; + return arr as T[]; +} + +export type RestOrArray = T[] | [T[]]; diff --git a/packages/discord.js/typings/index.d.ts b/packages/discord.js/typings/index.d.ts index 55b25f9d6..ef2c62621 100644 --- a/packages/discord.js/typings/index.d.ts +++ b/packages/discord.js/typings/index.d.ts @@ -29,6 +29,7 @@ import { ModalBuilder as BuildersModal, AnyComponentBuilder, ComponentBuilder, + type RestOrArray, } from '@discordjs/builders'; import { Collection } from '@discordjs/collection'; import { BaseImageURLOptions, ImageURLOptions, RawFile, REST, RESTOptions } from '@discordjs/rest'; @@ -608,10 +609,10 @@ export class ButtonBuilder extends BuilderButtonComponent { export class SelectMenuBuilder extends BuilderSelectMenuComponent { public constructor(data?: Partial); public override addOptions( - options: (BuildersSelectMenuOption | SelectMenuComponentOptionData | APISelectMenuOption)[], + ...options: RestOrArray ): this; public override setOptions( - options: (BuildersSelectMenuOption | SelectMenuComponentOptionData | APISelectMenuOption)[], + ...options: RestOrArray ): this; public static from(other: JSONEncodable | APISelectMenuComponent): SelectMenuBuilder; }