Skip to content

Commit b66f52f

Browse files
authored
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.
1 parent 126529f commit b66f52f

File tree

9 files changed

+157
-64
lines changed

9 files changed

+157
-64
lines changed

packages/builders/__tests__/components/selectMenu.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ const selectMenuDataWithoutOptions = {
2323
min_values: 1,
2424
disabled: true,
2525
placeholder: 'test',
26+
required: false,
2627
} as const;
2728

2829
const selectMenuData: APISelectMenuComponent = {

packages/builders/__tests__/interactions/modal.test.ts

Lines changed: 16 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { ComponentType, TextInputStyle, type APIModalInteractionResponseCallbackData } from 'discord-api-types/v10';
22
import { describe, test, expect } from 'vitest';
3-
import { ModalBuilder, TextInputBuilder, LabelBuilder } from '../../src/index.js';
3+
import { ModalBuilder, TextInputBuilder, LabelBuilder, TextDisplayBuilder } from '../../src/index.js';
44

55
const modal = () => new ModalBuilder();
66

@@ -9,19 +9,27 @@ const label = () =>
99
.setLabel('label')
1010
.setTextInputComponent(new TextInputBuilder().setCustomId('text').setStyle(TextInputStyle.Short));
1111

12+
const textDisplay = () => new TextDisplayBuilder().setContent('text');
13+
1214
describe('Modals', () => {
1315
test('GIVEN valid fields THEN builder does not throw', () => {
1416
expect(() =>
15-
modal().setTitle('test').setCustomId('foobar').setLabelComponents(label()).toJSON(),
17+
modal().setTitle('test').setCustomId('foobar').addLabelComponents(label()).toJSON(),
18+
).not.toThrowError();
19+
20+
expect(() =>
21+
modal().setTitle('test').setCustomId('foobar').addLabelComponents(label()).toJSON(),
1622
).not.toThrowError();
23+
1724
expect(() =>
18-
modal().setTitle('test').setCustomId('foobar').setLabelComponents(label()).toJSON(),
25+
modal().setTitle('test').setCustomId('foobar').addTextDisplayComponents(textDisplay()).toJSON(),
1926
).not.toThrowError();
2027
});
2128

2229
test('GIVEN invalid fields THEN builder does throw', () => {
2330
expect(() => modal().setTitle('test').setCustomId('foobar').toJSON()).toThrowError();
24-
// @ts-expect-error: CustomId is invalid
31+
32+
// @ts-expect-error: Custom id is invalid
2533
expect(() => modal().setTitle('test').setCustomId(42).toJSON()).toThrowError();
2634
});
2735

@@ -42,14 +50,8 @@ describe('Modals', () => {
4250
},
4351
},
4452
{
45-
type: ComponentType.Label,
46-
label: 'label',
47-
description: 'description',
48-
component: {
49-
type: ComponentType.TextInput,
50-
style: TextInputStyle.Paragraph,
51-
custom_id: 'custom id',
52-
},
53+
type: ComponentType.TextDisplay,
54+
content: 'yooooooooo',
5355
},
5456
],
5557
} satisfies APIModalInteractionResponseCallbackData;
@@ -60,19 +62,14 @@ describe('Modals', () => {
6062
modal()
6163
.setTitle(modalData.title)
6264
.setCustomId('custom id')
63-
.setLabelComponents(
64-
new LabelBuilder()
65-
.setId(33)
66-
.setLabel('label')
67-
.setDescription('description')
68-
.setTextInputComponent(new TextInputBuilder().setCustomId('custom id').setStyle(TextInputStyle.Paragraph)),
69-
)
7065
.addLabelComponents(
7166
new LabelBuilder()
67+
.setId(33)
7268
.setLabel('label')
7369
.setDescription('description')
7470
.setTextInputComponent(new TextInputBuilder().setCustomId('custom id').setStyle(TextInputStyle.Paragraph)),
7571
)
72+
.addTextDisplayComponents((textDisplay) => textDisplay.setContent('yooooooooo'))
7673
.toJSON(),
7774
).toEqual(modalData);
7875
});

packages/builders/src/components/Components.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,11 @@ export type ModalActionRowComponentBuilder = TextInputBuilder;
8989
*/
9090
export type AnyActionRowComponentBuilder = MessageActionRowComponentBuilder | ModalActionRowComponentBuilder;
9191

92+
/**
93+
* Any modal component builder.
94+
*/
95+
export type AnyModalComponentBuilder = LabelBuilder | TextDisplayBuilder;
96+
9297
/**
9398
* Components here are mapped to their respective builder.
9499
*/
Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,24 @@
11
import { ComponentType } from 'discord-api-types/v10';
22
import { z } from 'zod';
3-
import { selectMenuStringPredicate } from '../Assertions';
3+
import {
4+
selectMenuChannelPredicate,
5+
selectMenuMentionablePredicate,
6+
selectMenuRolePredicate,
7+
selectMenuStringPredicate,
8+
selectMenuUserPredicate,
9+
} from '../Assertions';
410
import { textInputPredicate } from '../textInput/Assertions';
511

612
export const labelPredicate = z.object({
713
type: z.literal(ComponentType.Label),
814
label: z.string().min(1).max(45),
915
description: z.string().min(1).max(100).optional(),
10-
component: z.union([selectMenuStringPredicate, textInputPredicate]),
16+
component: z.union([
17+
selectMenuStringPredicate,
18+
textInputPredicate,
19+
selectMenuUserPredicate,
20+
selectMenuRolePredicate,
21+
selectMenuMentionablePredicate,
22+
selectMenuChannelPredicate,
23+
]),
1124
});

packages/builders/src/components/label/Label.ts

Lines changed: 75 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,33 @@
1-
import type { APILabelComponent, APIStringSelectComponent, APITextInputComponent } from 'discord-api-types/v10';
1+
import type {
2+
APIChannelSelectComponent,
3+
APILabelComponent,
4+
APIMentionableSelectComponent,
5+
APIRoleSelectComponent,
6+
APIStringSelectComponent,
7+
APITextInputComponent,
8+
APIUserSelectComponent,
9+
} from 'discord-api-types/v10';
210
import { ComponentType } from 'discord-api-types/v10';
311
import { resolveBuilder } from '../../util/resolveBuilder.js';
412
import { validate } from '../../util/validation.js';
513
import { ComponentBuilder } from '../Component.js';
614
import { createComponentBuilder } from '../Components.js';
15+
import { ChannelSelectMenuBuilder } from '../selectMenu/ChannelSelectMenu.js';
16+
import { MentionableSelectMenuBuilder } from '../selectMenu/MentionableSelectMenu.js';
17+
import { RoleSelectMenuBuilder } from '../selectMenu/RoleSelectMenu.js';
718
import { StringSelectMenuBuilder } from '../selectMenu/StringSelectMenu.js';
19+
import { UserSelectMenuBuilder } from '../selectMenu/UserSelectMenu.js';
820
import { TextInputBuilder } from '../textInput/TextInput.js';
921
import { labelPredicate } from './Assertions.js';
1022

1123
export interface LabelBuilderData extends Partial<Omit<APILabelComponent, 'component'>> {
12-
component?: StringSelectMenuBuilder | TextInputBuilder;
24+
component?:
25+
| ChannelSelectMenuBuilder
26+
| MentionableSelectMenuBuilder
27+
| RoleSelectMenuBuilder
28+
| StringSelectMenuBuilder
29+
| TextInputBuilder
30+
| UserSelectMenuBuilder;
1331
}
1432

1533
/**
@@ -49,7 +67,6 @@ export class LabelBuilder extends ComponentBuilder<APILabelComponent> {
4967

5068
this.data = {
5169
...structuredClone(rest),
52-
// @ts-expect-error https://github.com/discordjs/discord.js/pull/11078
5370
component: component ? createComponentBuilder(component) : undefined,
5471
type: ComponentType.Label,
5572
};
@@ -98,6 +115,60 @@ export class LabelBuilder extends ComponentBuilder<APILabelComponent> {
98115
return this;
99116
}
100117

118+
/**
119+
* Sets a user select menu component to this label.
120+
*
121+
* @param input - A function that returns a component builder or an already built builder
122+
*/
123+
public setUserSelectMenuComponent(
124+
input: APIUserSelectComponent | UserSelectMenuBuilder | ((builder: UserSelectMenuBuilder) => UserSelectMenuBuilder),
125+
): this {
126+
this.data.component = resolveBuilder(input, UserSelectMenuBuilder);
127+
return this;
128+
}
129+
130+
/**
131+
* Sets a role select menu component to this label.
132+
*
133+
* @param input - A function that returns a component builder or an already built builder
134+
*/
135+
public setRoleSelectMenuComponent(
136+
input: APIRoleSelectComponent | RoleSelectMenuBuilder | ((builder: RoleSelectMenuBuilder) => RoleSelectMenuBuilder),
137+
): this {
138+
this.data.component = resolveBuilder(input, RoleSelectMenuBuilder);
139+
return this;
140+
}
141+
142+
/**
143+
* Sets a mentionable select menu component to this label.
144+
*
145+
* @param input - A function that returns a component builder or an already built builder
146+
*/
147+
public setMentionableSelectMenuComponent(
148+
input:
149+
| APIMentionableSelectComponent
150+
| MentionableSelectMenuBuilder
151+
| ((builder: MentionableSelectMenuBuilder) => MentionableSelectMenuBuilder),
152+
): this {
153+
this.data.component = resolveBuilder(input, MentionableSelectMenuBuilder);
154+
return this;
155+
}
156+
157+
/**
158+
* Sets a channel select menu component to this label.
159+
*
160+
* @param input - A function that returns a component builder or an already built builder
161+
*/
162+
public setChannelSelectMenuComponent(
163+
input:
164+
| APIChannelSelectComponent
165+
| ChannelSelectMenuBuilder
166+
| ((builder: ChannelSelectMenuBuilder) => ChannelSelectMenuBuilder),
167+
): this {
168+
this.data.component = resolveBuilder(input, ChannelSelectMenuBuilder);
169+
return this;
170+
}
171+
101172
/**
102173
* Sets a text input component to this label.
103174
*
@@ -118,6 +189,7 @@ export class LabelBuilder extends ComponentBuilder<APILabelComponent> {
118189

119190
const data = {
120191
...structuredClone(rest),
192+
// The label predicate validates the component.
121193
component: component?.toJSON(false),
122194
};
123195

packages/builders/src/components/selectMenu/BaseSelectMenu.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export abstract class BaseSelectMenuBuilder<Data extends APISelectMenuComponent>
1515
* @internal
1616
*/
1717
protected abstract override readonly data: Partial<
18-
Pick<Data, 'custom_id' | 'disabled' | 'id' | 'max_values' | 'min_values' | 'placeholder'>
18+
Pick<Data, 'custom_id' | 'disabled' | 'id' | 'max_values' | 'min_values' | 'placeholder' | 'required'>
1919
>;
2020

2121
/**
@@ -75,4 +75,15 @@ export abstract class BaseSelectMenuBuilder<Data extends APISelectMenuComponent>
7575
this.data.disabled = disabled;
7676
return this;
7777
}
78+
79+
/**
80+
* Sets whether this select menu is required.
81+
*
82+
* @remarks Only for use in modals.
83+
* @param required - Whether this string select menu is required
84+
*/
85+
public setRequired(required = true) {
86+
this.data.required = required;
87+
return this;
88+
}
7889
}

packages/builders/src/components/selectMenu/StringSelectMenu.ts

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -147,17 +147,6 @@ export class StringSelectMenuBuilder extends BaseSelectMenuBuilder<APIStringSele
147147
return this;
148148
}
149149

150-
/**
151-
* Sets whether this string select menu is required.
152-
*
153-
* @remarks Only for use in modals.
154-
* @param required - Whether this string select menu is required
155-
*/
156-
public setRequired(required = true) {
157-
this.data.required = required;
158-
return this;
159-
}
160-
161150
/**
162151
* {@inheritDoc ComponentBuilder.toJSON}
163152
*/

packages/builders/src/interactions/modals/Assertions.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { ComponentType } from 'discord-api-types/v10';
22
import { z } from 'zod';
33
import { customIdPredicate } from '../../Assertions.js';
44
import { labelPredicate } from '../../components/label/Assertions.js';
5+
import { textDisplayPredicate } from '../../components/v2/Assertions.js';
56

67
const titlePredicate = z.string().min(1).max(45);
78

@@ -18,6 +19,7 @@ export const modalPredicate = z.object({
1819
.length(1),
1920
}),
2021
labelPredicate,
22+
textDisplayPredicate,
2123
])
2224
.array()
2325
.min(1)

0 commit comments

Comments
 (0)