From b66f52f9aa0c2b869f393c58b8524629ac5fc75c Mon Sep 17 00:00:00 2001 From: Jiralite <33201955+Jiralite@users.noreply.github.com> Date: Sun, 14 Sep 2025 09:09:56 +0100 Subject: [PATCH] feat!: More label components and text display in modal (#11078) BREAKING CHANGE: Modals only have adding (no setting) and splicing has been replaced with a generalised splice method to support all components. --- .../__tests__/components/selectMenu.test.ts | 1 + .../__tests__/interactions/modal.test.ts | 35 ++++----- .../builders/src/components/Components.ts | 5 ++ .../src/components/label/Assertions.ts | 17 +++- .../builders/src/components/label/Label.ts | 78 ++++++++++++++++++- .../components/selectMenu/BaseSelectMenu.ts | 13 +++- .../components/selectMenu/StringSelectMenu.ts | 11 --- .../src/interactions/modals/Assertions.ts | 2 + .../builders/src/interactions/modals/Modal.ts | 59 +++++++------- 9 files changed, 157 insertions(+), 64 deletions(-) diff --git a/packages/builders/__tests__/components/selectMenu.test.ts b/packages/builders/__tests__/components/selectMenu.test.ts index 455813c21..544bf79a5 100644 --- a/packages/builders/__tests__/components/selectMenu.test.ts +++ b/packages/builders/__tests__/components/selectMenu.test.ts @@ -23,6 +23,7 @@ const selectMenuDataWithoutOptions = { min_values: 1, disabled: true, placeholder: 'test', + required: false, } as const; const selectMenuData: APISelectMenuComponent = { diff --git a/packages/builders/__tests__/interactions/modal.test.ts b/packages/builders/__tests__/interactions/modal.test.ts index bc416de5e..423273869 100644 --- a/packages/builders/__tests__/interactions/modal.test.ts +++ b/packages/builders/__tests__/interactions/modal.test.ts @@ -1,6 +1,6 @@ import { ComponentType, TextInputStyle, type APIModalInteractionResponseCallbackData } from 'discord-api-types/v10'; import { describe, test, expect } from 'vitest'; -import { ModalBuilder, TextInputBuilder, LabelBuilder } from '../../src/index.js'; +import { ModalBuilder, TextInputBuilder, LabelBuilder, TextDisplayBuilder } from '../../src/index.js'; const modal = () => new ModalBuilder(); @@ -9,19 +9,27 @@ const label = () => .setLabel('label') .setTextInputComponent(new TextInputBuilder().setCustomId('text').setStyle(TextInputStyle.Short)); +const textDisplay = () => new TextDisplayBuilder().setContent('text'); + describe('Modals', () => { test('GIVEN valid fields THEN builder does not throw', () => { expect(() => - modal().setTitle('test').setCustomId('foobar').setLabelComponents(label()).toJSON(), + modal().setTitle('test').setCustomId('foobar').addLabelComponents(label()).toJSON(), ).not.toThrowError(); + expect(() => - modal().setTitle('test').setCustomId('foobar').setLabelComponents(label()).toJSON(), + modal().setTitle('test').setCustomId('foobar').addLabelComponents(label()).toJSON(), + ).not.toThrowError(); + + expect(() => + modal().setTitle('test').setCustomId('foobar').addTextDisplayComponents(textDisplay()).toJSON(), ).not.toThrowError(); }); test('GIVEN invalid fields THEN builder does throw', () => { expect(() => modal().setTitle('test').setCustomId('foobar').toJSON()).toThrowError(); - // @ts-expect-error: CustomId is invalid + + // @ts-expect-error: Custom id is invalid expect(() => modal().setTitle('test').setCustomId(42).toJSON()).toThrowError(); }); @@ -42,14 +50,8 @@ describe('Modals', () => { }, }, { - type: ComponentType.Label, - label: 'label', - description: 'description', - component: { - type: ComponentType.TextInput, - style: TextInputStyle.Paragraph, - custom_id: 'custom id', - }, + type: ComponentType.TextDisplay, + content: 'yooooooooo', }, ], } satisfies APIModalInteractionResponseCallbackData; @@ -60,19 +62,14 @@ describe('Modals', () => { modal() .setTitle(modalData.title) .setCustomId('custom id') - .setLabelComponents( + .addLabelComponents( new LabelBuilder() .setId(33) .setLabel('label') .setDescription('description') .setTextInputComponent(new TextInputBuilder().setCustomId('custom id').setStyle(TextInputStyle.Paragraph)), ) - .addLabelComponents( - new LabelBuilder() - .setLabel('label') - .setDescription('description') - .setTextInputComponent(new TextInputBuilder().setCustomId('custom id').setStyle(TextInputStyle.Paragraph)), - ) + .addTextDisplayComponents((textDisplay) => textDisplay.setContent('yooooooooo')) .toJSON(), ).toEqual(modalData); }); diff --git a/packages/builders/src/components/Components.ts b/packages/builders/src/components/Components.ts index bf2c655fc..8abba582c 100644 --- a/packages/builders/src/components/Components.ts +++ b/packages/builders/src/components/Components.ts @@ -89,6 +89,11 @@ export type ModalActionRowComponentBuilder = TextInputBuilder; */ export type AnyActionRowComponentBuilder = MessageActionRowComponentBuilder | ModalActionRowComponentBuilder; +/** + * Any modal component builder. + */ +export type AnyModalComponentBuilder = LabelBuilder | TextDisplayBuilder; + /** * Components here are mapped to their respective builder. */ diff --git a/packages/builders/src/components/label/Assertions.ts b/packages/builders/src/components/label/Assertions.ts index 07118c3a6..0c62da1bf 100644 --- a/packages/builders/src/components/label/Assertions.ts +++ b/packages/builders/src/components/label/Assertions.ts @@ -1,11 +1,24 @@ import { ComponentType } from 'discord-api-types/v10'; import { z } from 'zod'; -import { selectMenuStringPredicate } from '../Assertions'; +import { + selectMenuChannelPredicate, + selectMenuMentionablePredicate, + selectMenuRolePredicate, + selectMenuStringPredicate, + selectMenuUserPredicate, +} from '../Assertions'; import { textInputPredicate } from '../textInput/Assertions'; export const labelPredicate = z.object({ type: z.literal(ComponentType.Label), label: z.string().min(1).max(45), description: z.string().min(1).max(100).optional(), - component: z.union([selectMenuStringPredicate, textInputPredicate]), + component: z.union([ + selectMenuStringPredicate, + textInputPredicate, + selectMenuUserPredicate, + selectMenuRolePredicate, + selectMenuMentionablePredicate, + selectMenuChannelPredicate, + ]), }); diff --git a/packages/builders/src/components/label/Label.ts b/packages/builders/src/components/label/Label.ts index 96632e019..a9353f7e5 100644 --- a/packages/builders/src/components/label/Label.ts +++ b/packages/builders/src/components/label/Label.ts @@ -1,15 +1,33 @@ -import type { APILabelComponent, APIStringSelectComponent, APITextInputComponent } from 'discord-api-types/v10'; +import type { + APIChannelSelectComponent, + APILabelComponent, + APIMentionableSelectComponent, + APIRoleSelectComponent, + APIStringSelectComponent, + APITextInputComponent, + APIUserSelectComponent, +} from 'discord-api-types/v10'; import { ComponentType } from 'discord-api-types/v10'; import { resolveBuilder } from '../../util/resolveBuilder.js'; import { validate } from '../../util/validation.js'; import { ComponentBuilder } from '../Component.js'; import { createComponentBuilder } from '../Components.js'; +import { ChannelSelectMenuBuilder } from '../selectMenu/ChannelSelectMenu.js'; +import { MentionableSelectMenuBuilder } from '../selectMenu/MentionableSelectMenu.js'; +import { RoleSelectMenuBuilder } from '../selectMenu/RoleSelectMenu.js'; import { StringSelectMenuBuilder } from '../selectMenu/StringSelectMenu.js'; +import { UserSelectMenuBuilder } from '../selectMenu/UserSelectMenu.js'; import { TextInputBuilder } from '../textInput/TextInput.js'; import { labelPredicate } from './Assertions.js'; export interface LabelBuilderData extends Partial> { - component?: StringSelectMenuBuilder | TextInputBuilder; + component?: + | ChannelSelectMenuBuilder + | MentionableSelectMenuBuilder + | RoleSelectMenuBuilder + | StringSelectMenuBuilder + | TextInputBuilder + | UserSelectMenuBuilder; } /** @@ -49,7 +67,6 @@ export class LabelBuilder extends ComponentBuilder { this.data = { ...structuredClone(rest), - // @ts-expect-error https://github.com/discordjs/discord.js/pull/11078 component: component ? createComponentBuilder(component) : undefined, type: ComponentType.Label, }; @@ -98,6 +115,60 @@ export class LabelBuilder extends ComponentBuilder { return this; } + /** + * Sets a user select menu component to this label. + * + * @param input - A function that returns a component builder or an already built builder + */ + public setUserSelectMenuComponent( + input: APIUserSelectComponent | UserSelectMenuBuilder | ((builder: UserSelectMenuBuilder) => UserSelectMenuBuilder), + ): this { + this.data.component = resolveBuilder(input, UserSelectMenuBuilder); + return this; + } + + /** + * Sets a role select menu component to this label. + * + * @param input - A function that returns a component builder or an already built builder + */ + public setRoleSelectMenuComponent( + input: APIRoleSelectComponent | RoleSelectMenuBuilder | ((builder: RoleSelectMenuBuilder) => RoleSelectMenuBuilder), + ): this { + this.data.component = resolveBuilder(input, RoleSelectMenuBuilder); + return this; + } + + /** + * Sets a mentionable select menu component to this label. + * + * @param input - A function that returns a component builder or an already built builder + */ + public setMentionableSelectMenuComponent( + input: + | APIMentionableSelectComponent + | MentionableSelectMenuBuilder + | ((builder: MentionableSelectMenuBuilder) => MentionableSelectMenuBuilder), + ): this { + this.data.component = resolveBuilder(input, MentionableSelectMenuBuilder); + return this; + } + + /** + * Sets a channel select menu component to this label. + * + * @param input - A function that returns a component builder or an already built builder + */ + public setChannelSelectMenuComponent( + input: + | APIChannelSelectComponent + | ChannelSelectMenuBuilder + | ((builder: ChannelSelectMenuBuilder) => ChannelSelectMenuBuilder), + ): this { + this.data.component = resolveBuilder(input, ChannelSelectMenuBuilder); + return this; + } + /** * Sets a text input component to this label. * @@ -118,6 +189,7 @@ export class LabelBuilder extends ComponentBuilder { const data = { ...structuredClone(rest), + // The label predicate validates the component. component: component?.toJSON(false), }; diff --git a/packages/builders/src/components/selectMenu/BaseSelectMenu.ts b/packages/builders/src/components/selectMenu/BaseSelectMenu.ts index 8afa8bc96..766b7efd8 100644 --- a/packages/builders/src/components/selectMenu/BaseSelectMenu.ts +++ b/packages/builders/src/components/selectMenu/BaseSelectMenu.ts @@ -15,7 +15,7 @@ export abstract class BaseSelectMenuBuilder * @internal */ protected abstract override readonly data: Partial< - Pick + Pick >; /** @@ -75,4 +75,15 @@ export abstract class BaseSelectMenuBuilder this.data.disabled = disabled; return this; } + + /** + * Sets whether this select menu is required. + * + * @remarks Only for use in modals. + * @param required - Whether this string select menu is required + */ + public setRequired(required = true) { + this.data.required = required; + return this; + } } diff --git a/packages/builders/src/components/selectMenu/StringSelectMenu.ts b/packages/builders/src/components/selectMenu/StringSelectMenu.ts index 14d3c126c..e2fda5b2a 100644 --- a/packages/builders/src/components/selectMenu/StringSelectMenu.ts +++ b/packages/builders/src/components/selectMenu/StringSelectMenu.ts @@ -147,17 +147,6 @@ export class StringSelectMenuBuilder extends BaseSelectMenuBuilder> { - components: (ActionRowBuilder | LabelBuilder)[]; + components: (ActionRowBuilder | AnyModalComponentBuilder)[]; } /** @@ -24,7 +30,7 @@ export class ModalBuilder implements JSONEncodable createComponentBuilder(component)), }; } @@ -80,56 +85,54 @@ export class ModalBuilder implements JSONEncodable LabelBuilder)> + public addTextDisplayComponents( + ...components: RestOrArray< + APITextDisplayComponent | TextDisplayBuilder | ((builder: TextDisplayBuilder) => TextDisplayBuilder) + > ) { const normalized = normalizeArray(components); - this.spliceLabelComponents(0, this.data.components.length, ...normalized); + const resolved = normalized.map((row) => resolveBuilder(row, TextDisplayBuilder)); + + this.data.components.push(...resolved); return this; } /** - * Removes, replaces, or inserts labels for this modal. + * Removes, replaces, or inserts components for this modal. * * @remarks * This method behaves similarly * to {@link https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array/splice | Array.prototype.splice()}. - * The maximum amount of labels that can be added is 5. + * The maximum amount of components that can be added is 5. * - * It's useful for modifying and adjusting order of the already-existing labels of a modal. + * It's useful for modifying and adjusting order of the already-existing components of a modal. * @example - * Remove the first label: + * Remove the first component: * ```ts - * modal.spliceLabelComponents(0, 1); + * modal.spliceComponents(0, 1); * ``` * @example - * Remove the first n labels: + * Remove the first n components: * ```ts * const n = 4; - * modal.spliceLabelComponents(0, n); + * modal.spliceComponents(0, n); * ``` * @example - * Remove the last label: + * Remove the last component: * ```ts - * modal.spliceLabelComponents(-1, 1); + * modal.spliceComponents(-1, 1); * ``` * @param index - The index to start at - * @param deleteCount - The number of labels to remove - * @param labels - The replacing label objects + * @param deleteCount - The number of components to remove + * @param components - The replacing components */ - public spliceLabelComponents( - index: number, - deleteCount: number, - ...labels: (APILabelComponent | LabelBuilder | ((builder: LabelBuilder) => LabelBuilder))[] - ): this { - const resolved = labels.map((label) => resolveBuilder(label, LabelBuilder)); - this.data.components.splice(index, deleteCount, ...resolved); - + public spliceComponents(index: number, deleteCount: number, ...components: AnyModalComponentBuilder[]): this { + this.data.components.splice(index, deleteCount, ...components); return this; }