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.
This commit is contained in:
Jiralite
2025-09-14 09:09:56 +01:00
committed by GitHub
parent 126529f460
commit b66f52f9aa
9 changed files with 157 additions and 64 deletions

View File

@@ -23,6 +23,7 @@ const selectMenuDataWithoutOptions = {
min_values: 1, min_values: 1,
disabled: true, disabled: true,
placeholder: 'test', placeholder: 'test',
required: false,
} as const; } as const;
const selectMenuData: APISelectMenuComponent = { const selectMenuData: APISelectMenuComponent = {

View File

@@ -1,6 +1,6 @@
import { ComponentType, TextInputStyle, type APIModalInteractionResponseCallbackData } from 'discord-api-types/v10'; import { ComponentType, TextInputStyle, type APIModalInteractionResponseCallbackData } from 'discord-api-types/v10';
import { describe, test, expect } from 'vitest'; 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(); const modal = () => new ModalBuilder();
@@ -9,19 +9,27 @@ const label = () =>
.setLabel('label') .setLabel('label')
.setTextInputComponent(new TextInputBuilder().setCustomId('text').setStyle(TextInputStyle.Short)); .setTextInputComponent(new TextInputBuilder().setCustomId('text').setStyle(TextInputStyle.Short));
const textDisplay = () => new TextDisplayBuilder().setContent('text');
describe('Modals', () => { describe('Modals', () => {
test('GIVEN valid fields THEN builder does not throw', () => { test('GIVEN valid fields THEN builder does not throw', () => {
expect(() => expect(() =>
modal().setTitle('test').setCustomId('foobar').setLabelComponents(label()).toJSON(), modal().setTitle('test').setCustomId('foobar').addLabelComponents(label()).toJSON(),
).not.toThrowError(); ).not.toThrowError();
expect(() => 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(); ).not.toThrowError();
}); });
test('GIVEN invalid fields THEN builder does throw', () => { test('GIVEN invalid fields THEN builder does throw', () => {
expect(() => modal().setTitle('test').setCustomId('foobar').toJSON()).toThrowError(); 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(); expect(() => modal().setTitle('test').setCustomId(42).toJSON()).toThrowError();
}); });
@@ -42,14 +50,8 @@ describe('Modals', () => {
}, },
}, },
{ {
type: ComponentType.Label, type: ComponentType.TextDisplay,
label: 'label', content: 'yooooooooo',
description: 'description',
component: {
type: ComponentType.TextInput,
style: TextInputStyle.Paragraph,
custom_id: 'custom id',
},
}, },
], ],
} satisfies APIModalInteractionResponseCallbackData; } satisfies APIModalInteractionResponseCallbackData;
@@ -60,19 +62,14 @@ describe('Modals', () => {
modal() modal()
.setTitle(modalData.title) .setTitle(modalData.title)
.setCustomId('custom id') .setCustomId('custom id')
.setLabelComponents( .addLabelComponents(
new LabelBuilder() new LabelBuilder()
.setId(33) .setId(33)
.setLabel('label') .setLabel('label')
.setDescription('description') .setDescription('description')
.setTextInputComponent(new TextInputBuilder().setCustomId('custom id').setStyle(TextInputStyle.Paragraph)), .setTextInputComponent(new TextInputBuilder().setCustomId('custom id').setStyle(TextInputStyle.Paragraph)),
) )
.addLabelComponents( .addTextDisplayComponents((textDisplay) => textDisplay.setContent('yooooooooo'))
new LabelBuilder()
.setLabel('label')
.setDescription('description')
.setTextInputComponent(new TextInputBuilder().setCustomId('custom id').setStyle(TextInputStyle.Paragraph)),
)
.toJSON(), .toJSON(),
).toEqual(modalData); ).toEqual(modalData);
}); });

View File

@@ -89,6 +89,11 @@ export type ModalActionRowComponentBuilder = TextInputBuilder;
*/ */
export type AnyActionRowComponentBuilder = MessageActionRowComponentBuilder | ModalActionRowComponentBuilder; export type AnyActionRowComponentBuilder = MessageActionRowComponentBuilder | ModalActionRowComponentBuilder;
/**
* Any modal component builder.
*/
export type AnyModalComponentBuilder = LabelBuilder | TextDisplayBuilder;
/** /**
* Components here are mapped to their respective builder. * Components here are mapped to their respective builder.
*/ */

View File

@@ -1,11 +1,24 @@
import { ComponentType } from 'discord-api-types/v10'; import { ComponentType } from 'discord-api-types/v10';
import { z } from 'zod'; import { z } from 'zod';
import { selectMenuStringPredicate } from '../Assertions'; import {
selectMenuChannelPredicate,
selectMenuMentionablePredicate,
selectMenuRolePredicate,
selectMenuStringPredicate,
selectMenuUserPredicate,
} from '../Assertions';
import { textInputPredicate } from '../textInput/Assertions'; import { textInputPredicate } from '../textInput/Assertions';
export const labelPredicate = z.object({ export const labelPredicate = z.object({
type: z.literal(ComponentType.Label), type: z.literal(ComponentType.Label),
label: z.string().min(1).max(45), label: z.string().min(1).max(45),
description: z.string().min(1).max(100).optional(), description: z.string().min(1).max(100).optional(),
component: z.union([selectMenuStringPredicate, textInputPredicate]), component: z.union([
selectMenuStringPredicate,
textInputPredicate,
selectMenuUserPredicate,
selectMenuRolePredicate,
selectMenuMentionablePredicate,
selectMenuChannelPredicate,
]),
}); });

View File

@@ -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 { ComponentType } from 'discord-api-types/v10';
import { resolveBuilder } from '../../util/resolveBuilder.js'; import { resolveBuilder } from '../../util/resolveBuilder.js';
import { validate } from '../../util/validation.js'; import { validate } from '../../util/validation.js';
import { ComponentBuilder } from '../Component.js'; import { ComponentBuilder } from '../Component.js';
import { createComponentBuilder } from '../Components.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 { StringSelectMenuBuilder } from '../selectMenu/StringSelectMenu.js';
import { UserSelectMenuBuilder } from '../selectMenu/UserSelectMenu.js';
import { TextInputBuilder } from '../textInput/TextInput.js'; import { TextInputBuilder } from '../textInput/TextInput.js';
import { labelPredicate } from './Assertions.js'; import { labelPredicate } from './Assertions.js';
export interface LabelBuilderData extends Partial<Omit<APILabelComponent, 'component'>> { export interface LabelBuilderData extends Partial<Omit<APILabelComponent, 'component'>> {
component?: StringSelectMenuBuilder | TextInputBuilder; component?:
| ChannelSelectMenuBuilder
| MentionableSelectMenuBuilder
| RoleSelectMenuBuilder
| StringSelectMenuBuilder
| TextInputBuilder
| UserSelectMenuBuilder;
} }
/** /**
@@ -49,7 +67,6 @@ export class LabelBuilder extends ComponentBuilder<APILabelComponent> {
this.data = { this.data = {
...structuredClone(rest), ...structuredClone(rest),
// @ts-expect-error https://github.com/discordjs/discord.js/pull/11078
component: component ? createComponentBuilder(component) : undefined, component: component ? createComponentBuilder(component) : undefined,
type: ComponentType.Label, type: ComponentType.Label,
}; };
@@ -98,6 +115,60 @@ export class LabelBuilder extends ComponentBuilder<APILabelComponent> {
return this; 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. * Sets a text input component to this label.
* *
@@ -118,6 +189,7 @@ export class LabelBuilder extends ComponentBuilder<APILabelComponent> {
const data = { const data = {
...structuredClone(rest), ...structuredClone(rest),
// The label predicate validates the component.
component: component?.toJSON(false), component: component?.toJSON(false),
}; };

View File

@@ -15,7 +15,7 @@ export abstract class BaseSelectMenuBuilder<Data extends APISelectMenuComponent>
* @internal * @internal
*/ */
protected abstract override readonly data: Partial< protected abstract override readonly data: Partial<
Pick<Data, 'custom_id' | 'disabled' | 'id' | 'max_values' | 'min_values' | 'placeholder'> Pick<Data, 'custom_id' | 'disabled' | 'id' | 'max_values' | 'min_values' | 'placeholder' | 'required'>
>; >;
/** /**
@@ -75,4 +75,15 @@ export abstract class BaseSelectMenuBuilder<Data extends APISelectMenuComponent>
this.data.disabled = disabled; this.data.disabled = disabled;
return this; 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;
}
} }

View File

@@ -147,17 +147,6 @@ export class StringSelectMenuBuilder extends BaseSelectMenuBuilder<APIStringSele
return this; return this;
} }
/**
* Sets whether this string 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;
}
/** /**
* {@inheritDoc ComponentBuilder.toJSON} * {@inheritDoc ComponentBuilder.toJSON}
*/ */

View File

@@ -2,6 +2,7 @@ import { ComponentType } from 'discord-api-types/v10';
import { z } from 'zod'; import { z } from 'zod';
import { customIdPredicate } from '../../Assertions.js'; import { customIdPredicate } from '../../Assertions.js';
import { labelPredicate } from '../../components/label/Assertions.js'; import { labelPredicate } from '../../components/label/Assertions.js';
import { textDisplayPredicate } from '../../components/v2/Assertions.js';
const titlePredicate = z.string().min(1).max(45); const titlePredicate = z.string().min(1).max(45);
@@ -18,6 +19,7 @@ export const modalPredicate = z.object({
.length(1), .length(1),
}), }),
labelPredicate, labelPredicate,
textDisplayPredicate,
]) ])
.array() .array()
.min(1) .min(1)

View File

@@ -1,15 +1,21 @@
import type { JSONEncodable } from '@discordjs/util'; import type { JSONEncodable } from '@discordjs/util';
import type { APILabelComponent, APIModalInteractionResponseCallbackData } from 'discord-api-types/v10'; import type {
APILabelComponent,
APIModalInteractionResponseCallbackData,
APITextDisplayComponent,
} from 'discord-api-types/v10';
import type { ActionRowBuilder } from '../../components/ActionRow.js'; import type { ActionRowBuilder } from '../../components/ActionRow.js';
import type { AnyModalComponentBuilder } from '../../components/Components.js';
import { createComponentBuilder } from '../../components/Components.js'; import { createComponentBuilder } from '../../components/Components.js';
import { LabelBuilder } from '../../components/label/Label.js'; import { LabelBuilder } from '../../components/label/Label.js';
import { TextDisplayBuilder } from '../../components/v2/TextDisplay.js';
import { normalizeArray, type RestOrArray } from '../../util/normalizeArray.js'; import { normalizeArray, type RestOrArray } from '../../util/normalizeArray.js';
import { resolveBuilder } from '../../util/resolveBuilder.js'; import { resolveBuilder } from '../../util/resolveBuilder.js';
import { validate } from '../../util/validation.js'; import { validate } from '../../util/validation.js';
import { modalPredicate } from './Assertions.js'; import { modalPredicate } from './Assertions.js';
export interface ModalBuilderData extends Partial<Omit<APIModalInteractionResponseCallbackData, 'components'>> { export interface ModalBuilderData extends Partial<Omit<APIModalInteractionResponseCallbackData, 'components'>> {
components: (ActionRowBuilder | LabelBuilder)[]; components: (ActionRowBuilder | AnyModalComponentBuilder)[];
} }
/** /**
@@ -24,7 +30,7 @@ export class ModalBuilder implements JSONEncodable<APIModalInteractionResponseCa
/** /**
* The components within this modal. * The components within this modal.
*/ */
public get components(): readonly (ActionRowBuilder | LabelBuilder)[] { public get components(): readonly (ActionRowBuilder | AnyModalComponentBuilder)[] {
return this.data.components; return this.data.components;
} }
@@ -38,7 +44,6 @@ export class ModalBuilder implements JSONEncodable<APIModalInteractionResponseCa
this.data = { this.data = {
...structuredClone(rest), ...structuredClone(rest),
// @ts-expect-error https://github.com/discordjs/discord.js/pull/11078
components: components.map((component) => createComponentBuilder(component)), components: components.map((component) => createComponentBuilder(component)),
}; };
} }
@@ -80,56 +85,54 @@ export class ModalBuilder implements JSONEncodable<APIModalInteractionResponseCa
} }
/** /**
* Sets the labels for this modal. * Adds text display components to this modal.
* *
* @param components - The components to set * @param components - The components to add
*/ */
public setLabelComponents( public addTextDisplayComponents(
...components: RestOrArray<APILabelComponent | LabelBuilder | ((builder: LabelBuilder) => LabelBuilder)> ...components: RestOrArray<
APITextDisplayComponent | TextDisplayBuilder | ((builder: TextDisplayBuilder) => TextDisplayBuilder)
>
) { ) {
const normalized = normalizeArray(components); 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; return this;
} }
/** /**
* Removes, replaces, or inserts labels for this modal. * Removes, replaces, or inserts components for this modal.
* *
* @remarks * @remarks
* This method behaves similarly * This method behaves similarly
* to {@link https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array/splice | Array.prototype.splice()}. * 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 * @example
* Remove the first label: * Remove the first component:
* ```ts * ```ts
* modal.spliceLabelComponents(0, 1); * modal.spliceComponents(0, 1);
* ``` * ```
* @example * @example
* Remove the first n labels: * Remove the first n components:
* ```ts * ```ts
* const n = 4; * const n = 4;
* modal.spliceLabelComponents(0, n); * modal.spliceComponents(0, n);
* ``` * ```
* @example * @example
* Remove the last label: * Remove the last component:
* ```ts * ```ts
* modal.spliceLabelComponents(-1, 1); * modal.spliceComponents(-1, 1);
* ``` * ```
* @param index - The index to start at * @param index - The index to start at
* @param deleteCount - The number of labels to remove * @param deleteCount - The number of components to remove
* @param labels - The replacing label objects * @param components - The replacing components
*/ */
public spliceLabelComponents( public spliceComponents(index: number, deleteCount: number, ...components: AnyModalComponentBuilder[]): this {
index: number, this.data.components.splice(index, deleteCount, ...components);
deleteCount: number,
...labels: (APILabelComponent | LabelBuilder | ((builder: LabelBuilder) => LabelBuilder))[]
): this {
const resolved = labels.map((label) => resolveBuilder(label, LabelBuilder));
this.data.components.splice(index, deleteCount, ...resolved);
return this; return this;
} }