Skip to content

Commit f7c77a7

Browse files
authored
feat(builders)!: Support select in modals (#11034)
BREAKING CHANGE: Text inputs no longer accept a label. BREAKING CHANGE: Modals now only set labels instead of action rows.
1 parent ddf9f81 commit f7c77a7

File tree

13 files changed

+364
-115
lines changed

13 files changed

+364
-115
lines changed
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import type { APILabelComponent, APIStringSelectComponent, APITextInputComponent } from 'discord-api-types/v10';
2+
import { ComponentType, TextInputStyle } from 'discord-api-types/v10';
3+
import { describe, test, expect } from 'vitest';
4+
import { LabelBuilder } from '../../src/index.js';
5+
6+
describe('Label components', () => {
7+
describe('Assertion Tests', () => {
8+
test('GIVEN valid fields THEN builder does not throw', () => {
9+
expect(() =>
10+
new LabelBuilder()
11+
.setLabel('label')
12+
.setStringSelectMenuComponent((stringSelectMenu) =>
13+
stringSelectMenu
14+
.setCustomId('test')
15+
.setOptions((stringSelectMenuOption) => stringSelectMenuOption.setLabel('label').setValue('value'))
16+
.setRequired(),
17+
)
18+
.toJSON(),
19+
).not.toThrow();
20+
21+
expect(() =>
22+
new LabelBuilder()
23+
.setLabel('label')
24+
.setId(5)
25+
.setTextInputComponent((textInput) =>
26+
textInput.setCustomId('test').setStyle(TextInputStyle.Paragraph).setRequired(),
27+
)
28+
.toJSON(),
29+
).not.toThrow();
30+
});
31+
32+
test('GIVEN invalid fields THEN build does throw', () => {
33+
expect(() => new LabelBuilder().toJSON()).toThrow();
34+
expect(() => new LabelBuilder().setId(5).toJSON()).toThrow();
35+
expect(() => new LabelBuilder().setLabel('label').toJSON()).toThrow();
36+
37+
expect(() =>
38+
new LabelBuilder()
39+
.setLabel('l'.repeat(1_000))
40+
.setStringSelectMenuComponent((stringSelectMenu) => stringSelectMenu)
41+
.toJSON(),
42+
).toThrow();
43+
});
44+
45+
test('GIVEN valid input THEN valid JSON outputs are given', () => {
46+
const labelWithTextInputData = {
47+
type: ComponentType.Label,
48+
component: {
49+
type: ComponentType.TextInput,
50+
custom_id: 'custom_id',
51+
placeholder: 'placeholder',
52+
style: TextInputStyle.Paragraph,
53+
} satisfies APITextInputComponent,
54+
label: 'label',
55+
description: 'description',
56+
id: 5,
57+
} satisfies APILabelComponent;
58+
59+
const labelWithStringSelectData = {
60+
type: ComponentType.Label,
61+
component: {
62+
type: ComponentType.StringSelect,
63+
custom_id: 'custom_id',
64+
placeholder: 'placeholder',
65+
options: [
66+
{ label: 'first', value: 'first' },
67+
{ label: 'second', value: 'second' },
68+
],
69+
required: true,
70+
} satisfies APIStringSelectComponent,
71+
label: 'label',
72+
description: 'description',
73+
id: 5,
74+
} satisfies APILabelComponent;
75+
76+
expect(new LabelBuilder(labelWithTextInputData).toJSON()).toEqual(labelWithTextInputData);
77+
expect(new LabelBuilder(labelWithStringSelectData).toJSON()).toEqual(labelWithStringSelectData);
78+
79+
expect(
80+
new LabelBuilder()
81+
.setTextInputComponent((textInput) =>
82+
textInput.setCustomId('custom_id').setPlaceholder('placeholder').setStyle(TextInputStyle.Paragraph),
83+
)
84+
.setLabel('label')
85+
.setDescription('description')
86+
.setId(5)
87+
.toJSON(),
88+
).toEqual(labelWithTextInputData);
89+
90+
expect(
91+
new LabelBuilder()
92+
.setStringSelectMenuComponent((stringSelectMenu) =>
93+
stringSelectMenu
94+
.setCustomId('custom_id')
95+
.setPlaceholder('placeholder')
96+
.setOptions(
97+
(stringSelectMenuOption) => stringSelectMenuOption.setLabel('first').setValue('first'),
98+
(stringSelectMenuOption) => stringSelectMenuOption.setLabel('second').setValue('second'),
99+
)
100+
.setRequired(),
101+
)
102+
.setLabel('label')
103+
.setDescription('description')
104+
.setId(5)
105+
.toJSON(),
106+
).toEqual(labelWithStringSelectData);
107+
});
108+
});
109+
});

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

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,12 @@ describe('Text Input Components', () => {
88
describe('Assertion Tests', () => {
99
test('GIVEN valid fields THEN builder does not throw', () => {
1010
expect(() => {
11-
textInputComponent().setCustomId('foobar').setLabel('test').setStyle(TextInputStyle.Paragraph).toJSON();
11+
textInputComponent().setCustomId('foobar').setStyle(TextInputStyle.Paragraph).toJSON();
1212
}).not.toThrowError();
1313

1414
expect(() => {
1515
textInputComponent()
1616
.setCustomId('foobar')
17-
.setLabel('test')
1817
.setMaxLength(100)
1918
.setMinLength(1)
2019
.setPlaceholder('bar')
@@ -24,7 +23,7 @@ describe('Text Input Components', () => {
2423
}).not.toThrowError();
2524

2625
expect(() => {
27-
textInputComponent().setCustomId('Custom').setLabel('Guess').setStyle(TextInputStyle.Short).toJSON();
26+
textInputComponent().setCustomId('Custom').setStyle(TextInputStyle.Short).toJSON();
2827
}).not.toThrowError();
2928
});
3029
});
@@ -33,18 +32,17 @@ describe('Text Input Components', () => {
3332
expect(() => textInputComponent().toJSON()).toThrowError();
3433
expect(() => {
3534
textInputComponent()
36-
.setCustomId('test')
35+
.setCustomId('a'.repeat(500))
3736
.setMaxLength(100)
38-
.setPlaceholder('hello')
39-
.setStyle(TextInputStyle.Paragraph)
37+
.setPlaceholder('a'.repeat(500))
38+
.setStyle(3 as TextInputStyle)
4039
.toJSON();
4140
}).toThrowError();
4241
});
4342

4443
test('GIVEN valid input THEN valid JSON outputs are given', () => {
4544
const textInputData = {
4645
type: ComponentType.TextInput,
47-
label: 'label',
4846
custom_id: 'custom id',
4947
placeholder: 'placeholder',
5048
max_length: 100,
@@ -58,11 +56,10 @@ describe('Text Input Components', () => {
5856
expect(
5957
textInputComponent()
6058
.setCustomId(textInputData.custom_id)
61-
.setLabel(textInputData.label)
62-
.setPlaceholder(textInputData.placeholder!)
63-
.setMaxLength(textInputData.max_length!)
64-
.setMinLength(textInputData.min_length!)
65-
.setValue(textInputData.value!)
59+
.setPlaceholder(textInputData.placeholder)
60+
.setMaxLength(textInputData.max_length)
61+
.setMinLength(textInputData.min_length)
62+
.setValue(textInputData.value)
6663
.setRequired(textInputData.required)
6764
.setStyle(textInputData.style)
6865
.toJSON(),

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

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

55
const modal = () => new ModalBuilder();
6-
const textInput = () =>
7-
new ActionRowBuilder().addTextInputComponent(
8-
new TextInputBuilder().setCustomId('text').setLabel(':3').setStyle(TextInputStyle.Short),
9-
);
6+
7+
const label = () =>
8+
new LabelBuilder()
9+
.setLabel('label')
10+
.setTextInputComponent(new TextInputBuilder().setCustomId('text').setStyle(TextInputStyle.Short));
1011

1112
describe('Modals', () => {
1213
test('GIVEN valid fields THEN builder does not throw', () => {
13-
expect(() => modal().setTitle('test').setCustomId('foobar').setActionRows(textInput()).toJSON()).not.toThrowError();
14-
expect(() => modal().setTitle('test').setCustomId('foobar').addActionRows(textInput()).toJSON()).not.toThrowError();
14+
expect(() =>
15+
modal().setTitle('test').setCustomId('foobar').setLabelComponents(label()).toJSON(),
16+
).not.toThrowError();
17+
expect(() =>
18+
modal().setTitle('test').setCustomId('foobar').setLabelComponents(label()).toJSON(),
19+
).not.toThrowError();
1520
});
1621

1722
test('GIVEN invalid fields THEN builder does throw', () => {
@@ -21,51 +26,53 @@ describe('Modals', () => {
2126
});
2227

2328
test('GIVEN valid input THEN valid JSON outputs are given', () => {
24-
const modalData: APIModalInteractionResponseCallbackData = {
29+
const modalData = {
2530
title: 'title',
2631
custom_id: 'custom id',
2732
components: [
2833
{
29-
type: ComponentType.ActionRow,
30-
components: [
31-
{
32-
type: ComponentType.TextInput,
33-
label: 'label',
34-
style: TextInputStyle.Paragraph,
35-
custom_id: 'custom id',
36-
},
37-
],
34+
type: ComponentType.Label,
35+
id: 33,
36+
label: 'label',
37+
description: 'description',
38+
component: {
39+
type: ComponentType.TextInput,
40+
style: TextInputStyle.Paragraph,
41+
custom_id: 'custom id',
42+
},
3843
},
3944
{
40-
type: ComponentType.ActionRow,
41-
components: [
42-
{
43-
type: ComponentType.TextInput,
44-
label: 'label',
45-
style: TextInputStyle.Paragraph,
46-
custom_id: 'custom id',
47-
},
48-
],
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+
},
4953
},
5054
],
51-
};
55+
} satisfies APIModalInteractionResponseCallbackData;
5256

5357
expect(new ModalBuilder(modalData).toJSON()).toEqual(modalData);
5458

5559
expect(
5660
modal()
5761
.setTitle(modalData.title)
5862
.setCustomId('custom id')
59-
.setActionRows(
60-
new ActionRowBuilder().addTextInputComponent(
61-
new TextInputBuilder().setCustomId('custom id').setLabel('label').setStyle(TextInputStyle.Paragraph),
62-
),
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+
)
70+
.addLabelComponents(
71+
new LabelBuilder()
72+
.setLabel('label')
73+
.setDescription('description')
74+
.setTextInputComponent(new TextInputBuilder().setCustomId('custom id').setStyle(TextInputStyle.Paragraph)),
6375
)
64-
.addActionRows([
65-
new ActionRowBuilder().addTextInputComponent(
66-
new TextInputBuilder().setCustomId('custom id').setLabel('label').setStyle(TextInputStyle.Paragraph),
67-
),
68-
])
6976
.toJSON(),
7077
).toEqual(modalData);
7178
});

packages/builders/__tests__/messages/message.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ describe('Message', () => {
1919

2020
test('GIVEN bad action row THEN it throws', () => {
2121
const message = new MessageBuilder().addActionRowComponents((row) =>
22-
row.addTextInputComponent((input) => input.setCustomId('abc').setLabel('def')),
22+
row.addTextInputComponent((input) => input.setCustomId('abc')),
2323
);
2424
expect(() => message.toJSON()).toThrow();
2525
});

packages/builders/src/components/Components.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
} from './button/CustomIdButton.js';
1818
import { LinkButtonBuilder } from './button/LinkButton.js';
1919
import { PremiumButtonBuilder } from './button/PremiumButton.js';
20+
import { LabelBuilder } from './label/Label.js';
2021
import { ChannelSelectMenuBuilder } from './selectMenu/ChannelSelectMenu.js';
2122
import { MentionableSelectMenuBuilder } from './selectMenu/MentionableSelectMenu.js';
2223
import { RoleSelectMenuBuilder } from './selectMenu/RoleSelectMenu.js';
@@ -54,7 +55,7 @@ export type MessageComponentBuilder =
5455
/**
5556
* The builders that may be used for modals.
5657
*/
57-
export type ModalComponentBuilder = ActionRowBuilder | ModalActionRowComponentBuilder;
58+
export type ModalComponentBuilder = ActionRowBuilder | LabelBuilder | ModalActionRowComponentBuilder;
5859

5960
/**
6061
* Any button builder
@@ -152,6 +153,10 @@ export interface MappedComponentTypes {
152153
* The container component type is associated with a {@link ContainerBuilder}.
153154
*/
154155
[ComponentType.Container]: ContainerBuilder;
156+
/**
157+
* The label component type is associated with a {@link LabelBuilder}.
158+
*/
159+
[ComponentType.Label]: LabelBuilder;
155160
}
156161

157162
/**
@@ -182,8 +187,6 @@ export function createComponentBuilder(
182187
return data;
183188
}
184189

185-
// https://github.com/discordjs/discord.js/pull/11034
186-
// eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check
187190
switch (data.type) {
188191
case ComponentType.ActionRow:
189192
return new ActionRowBuilder(data);
@@ -215,7 +218,10 @@ export function createComponentBuilder(
215218
return new SectionBuilder(data);
216219
case ComponentType.Container:
217220
return new ContainerBuilder(data);
221+
case ComponentType.Label:
222+
return new LabelBuilder(data);
218223
default:
224+
// @ts-expect-error This case can still occur if we get a newer unsupported component type
219225
throw new Error(`Cannot properly serialize component type: ${data.type}`);
220226
}
221227
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { ComponentType } from 'discord-api-types/v10';
2+
import { z } from 'zod';
3+
import { selectMenuStringPredicate } from '../Assertions';
4+
import { textInputPredicate } from '../textInput/Assertions';
5+
6+
export const labelPredicate = z.object({
7+
type: z.literal(ComponentType.Label),
8+
label: z.string().min(1).max(45),
9+
description: z.string().min(1).max(100).optional(),
10+
component: z.union([selectMenuStringPredicate, textInputPredicate]),
11+
});

0 commit comments

Comments
 (0)