Skip to content

Commit ac683b9

Browse files
vladfranguJiraliteCopilot
authored
feat(builders): modal select menus in builders v1 (#11138)
* feat(builders): modal select menus * chore: fix test * fix: move setRequired up * fix: pack * chore: forwardport #11139 * types: expect errors * fix: id validator being required * fix: validators 2 * Apply suggestion from @Jiralite * Apply suggestion from @Jiralite * Apply suggestion from @Jiralite * fix: replace tests * Apply suggestion from @Copilot Co-authored-by: Copilot <[email protected]> * Apply suggestion from @Copilot Co-authored-by: Copilot <[email protected]> --------- Co-authored-by: Jiralite <[email protected]> Co-authored-by: Copilot <[email protected]>
1 parent 437bb94 commit ac683b9

File tree

17 files changed

+672
-2709
lines changed

17 files changed

+672
-2709
lines changed

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ describe('Text Input Components', () => {
100100
.setPlaceholder('hello')
101101
.setStyle(TextInputStyle.Paragraph)
102102
.toJSON();
103-
}).toThrowError();
103+
}).not.toThrowError();
104104
});
105105

106106
test('GIVEN valid input THEN valid JSON outputs are given', () => {

packages/builders/__tests__/components/v2/container.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { type APIContainerComponent, ComponentType, SeparatorSpacingSize } from 'discord-api-types/v10';
22
import { describe, test, expect } from 'vitest';
3-
import { ButtonBuilder } from '../../../dist/index.mjs';
43
import { ActionRowBuilder } from '../../../src/components/ActionRow.js';
54
import { createComponentBuilder } from '../../../src/components/Components.js';
5+
import { ButtonBuilder } from '../../../src/components/button/Button.js';
66
import { ContainerBuilder } from '../../../src/components/v2/Container.js';
77
import { FileBuilder } from '../../../src/components/v2/File.js';
88
import { MediaGalleryBuilder } from '../../../src/components/v2/MediaGallery.js';

packages/builders/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@
6868
"@discordjs/formatters": "workspace:^",
6969
"@discordjs/util": "workspace:^",
7070
"@sapphire/shapeshift": "^4.0.0",
71-
"discord-api-types": "^0.38.16",
71+
"discord-api-types": "^0.38.26",
7272
"fast-deep-equal": "^3.1.3",
7373
"ts-mixer": "^6.0.4",
7474
"tslib": "^2.6.3"

packages/builders/src/components/Component.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type {
55
APIBaseComponent,
66
ComponentType,
77
APIMessageComponent,
8+
APIModalComponent,
89
} from 'discord-api-types/v10';
910
import { idValidator } from './Assertions';
1011

@@ -14,7 +15,8 @@ import { idValidator } from './Assertions';
1415
export type AnyAPIActionRowComponent =
1516
| APIActionRowComponent<APIComponentInActionRow>
1617
| APIComponentInActionRow
17-
| APIMessageComponent;
18+
| APIMessageComponent
19+
| APIModalComponent;
1820

1921
/**
2022
* The base component builder that contains common symbols for all sorts of components.

packages/builders/src/components/Components.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
} from './ActionRow.js';
99
import { ComponentBuilder } from './Component.js';
1010
import { ButtonBuilder } from './button/Button.js';
11+
import { LabelBuilder } from './label/Label.js';
1112
import { ChannelSelectMenuBuilder } from './selectMenu/ChannelSelectMenu.js';
1213
import { MentionableSelectMenuBuilder } from './selectMenu/MentionableSelectMenu.js';
1314
import { RoleSelectMenuBuilder } from './selectMenu/RoleSelectMenu.js';
@@ -100,6 +101,10 @@ export interface MappedComponentTypes {
100101
* The media gallery component type is associated with a {@link MediaGalleryBuilder}.
101102
*/
102103
[ComponentType.MediaGallery]: MediaGalleryBuilder;
104+
/**
105+
* The label component type is associated with a {@link LabelBuilder}.
106+
*/
107+
[ComponentType.Label]: LabelBuilder;
103108
}
104109

105110
/**
@@ -161,6 +166,8 @@ export function createComponentBuilder(
161166
return new ThumbnailBuilder(data);
162167
case ComponentType.MediaGallery:
163168
return new MediaGalleryBuilder(data);
169+
case ComponentType.Label:
170+
return new LabelBuilder(data);
164171
default:
165172
// @ts-expect-error This case can still occur if we get a newer unsupported component type
166173
throw new Error(`Cannot properly serialize component type: ${data.type}`);
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { s } from '@sapphire/shapeshift';
2+
import { ComponentType } from 'discord-api-types/v10';
3+
import { isValidationEnabled } from '../../util/validation.js';
4+
import { idValidator } from '../Assertions.js';
5+
import {
6+
selectMenuChannelPredicate,
7+
selectMenuMentionablePredicate,
8+
selectMenuRolePredicate,
9+
selectMenuStringPredicate,
10+
selectMenuUserPredicate,
11+
} from '../selectMenu/Assertions.js';
12+
import { textInputPredicate } from '../textInput/Assertions.js';
13+
14+
export const labelPredicate = s
15+
.object({
16+
id: idValidator.optional(),
17+
type: s.literal(ComponentType.Label),
18+
label: s.string().lengthGreaterThanOrEqual(1).lengthLessThanOrEqual(45),
19+
description: s.string().lengthGreaterThanOrEqual(1).lengthLessThanOrEqual(100).optional(),
20+
component: s.union([
21+
textInputPredicate,
22+
selectMenuUserPredicate,
23+
selectMenuRolePredicate,
24+
selectMenuMentionablePredicate,
25+
selectMenuChannelPredicate,
26+
selectMenuStringPredicate,
27+
]),
28+
})
29+
.setValidationEnabled(isValidationEnabled);
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
import type {
2+
APIChannelSelectComponent,
3+
APILabelComponent,
4+
APIMentionableSelectComponent,
5+
APIRoleSelectComponent,
6+
APIStringSelectComponent,
7+
APITextInputComponent,
8+
APIUserSelectComponent,
9+
} from 'discord-api-types/v10';
10+
import { ComponentType } from 'discord-api-types/v10';
11+
import { ComponentBuilder } from '../Component.js';
12+
import { createComponentBuilder, resolveBuilder } from '../Components.js';
13+
import { ChannelSelectMenuBuilder } from '../selectMenu/ChannelSelectMenu.js';
14+
import { MentionableSelectMenuBuilder } from '../selectMenu/MentionableSelectMenu.js';
15+
import { RoleSelectMenuBuilder } from '../selectMenu/RoleSelectMenu.js';
16+
import { StringSelectMenuBuilder } from '../selectMenu/StringSelectMenu.js';
17+
import { UserSelectMenuBuilder } from '../selectMenu/UserSelectMenu.js';
18+
import { TextInputBuilder } from '../textInput/TextInput.js';
19+
import { labelPredicate } from './Assertions.js';
20+
21+
export interface LabelBuilderData extends Partial<Omit<APILabelComponent, 'component'>> {
22+
component?:
23+
| ChannelSelectMenuBuilder
24+
| MentionableSelectMenuBuilder
25+
| RoleSelectMenuBuilder
26+
| StringSelectMenuBuilder
27+
| TextInputBuilder
28+
| UserSelectMenuBuilder;
29+
}
30+
31+
/**
32+
* A builder that creates API-compatible JSON data for labels.
33+
*/
34+
export class LabelBuilder extends ComponentBuilder<LabelBuilderData> {
35+
/**
36+
* @internal
37+
*/
38+
public override readonly data: LabelBuilderData;
39+
40+
/**
41+
* Creates a new label.
42+
*
43+
* @param data - The API data to create this label with
44+
* @example
45+
* Creating a label from an API data object:
46+
* ```ts
47+
* const label = new LabelBuilder({
48+
* label: "label",
49+
* component,
50+
* });
51+
* ```
52+
* @example
53+
* Creating a label using setters and API data:
54+
* ```ts
55+
* const label = new LabelBuilder({
56+
* label: 'label',
57+
* component,
58+
* }).setLabel('new text');
59+
* ```
60+
*/
61+
public constructor(data: Partial<APILabelComponent> = {}) {
62+
super({ type: ComponentType.Label });
63+
64+
const { component, ...rest } = data;
65+
66+
this.data = {
67+
...rest,
68+
component: component ? createComponentBuilder(component) : undefined,
69+
type: ComponentType.Label,
70+
};
71+
}
72+
73+
/**
74+
* Sets the label for this label.
75+
*
76+
* @param label - The label to use
77+
*/
78+
public setLabel(label: string) {
79+
this.data.label = label;
80+
return this;
81+
}
82+
83+
/**
84+
* Sets the description for this label.
85+
*
86+
* @param description - The description to use
87+
*/
88+
public setDescription(description: string) {
89+
this.data.description = description;
90+
return this;
91+
}
92+
93+
/**
94+
* Clears the description for this label.
95+
*/
96+
public clearDescription() {
97+
this.data.description = undefined;
98+
return this;
99+
}
100+
101+
/**
102+
* Sets a string select menu component to this label.
103+
*
104+
* @param input - A function that returns a component builder or an already built builder
105+
*/
106+
public setStringSelectMenuComponent(
107+
input:
108+
| APIStringSelectComponent
109+
| StringSelectMenuBuilder
110+
| ((builder: StringSelectMenuBuilder) => StringSelectMenuBuilder),
111+
): this {
112+
this.data.component = resolveBuilder(input, StringSelectMenuBuilder);
113+
return this;
114+
}
115+
116+
/**
117+
* Sets a user select menu component to this label.
118+
*
119+
* @param input - A function that returns a component builder or an already built builder
120+
*/
121+
public setUserSelectMenuComponent(
122+
input: APIUserSelectComponent | UserSelectMenuBuilder | ((builder: UserSelectMenuBuilder) => UserSelectMenuBuilder),
123+
): this {
124+
this.data.component = resolveBuilder(input, UserSelectMenuBuilder);
125+
return this;
126+
}
127+
128+
/**
129+
* Sets a role select menu component to this label.
130+
*
131+
* @param input - A function that returns a component builder or an already built builder
132+
*/
133+
public setRoleSelectMenuComponent(
134+
input: APIRoleSelectComponent | RoleSelectMenuBuilder | ((builder: RoleSelectMenuBuilder) => RoleSelectMenuBuilder),
135+
): this {
136+
this.data.component = resolveBuilder(input, RoleSelectMenuBuilder);
137+
return this;
138+
}
139+
140+
/**
141+
* Sets a mentionable select menu component to this label.
142+
*
143+
* @param input - A function that returns a component builder or an already built builder
144+
*/
145+
public setMentionableSelectMenuComponent(
146+
input:
147+
| APIMentionableSelectComponent
148+
| MentionableSelectMenuBuilder
149+
| ((builder: MentionableSelectMenuBuilder) => MentionableSelectMenuBuilder),
150+
): this {
151+
this.data.component = resolveBuilder(input, MentionableSelectMenuBuilder);
152+
return this;
153+
}
154+
155+
/**
156+
* Sets a channel select menu component to this label.
157+
*
158+
* @param input - A function that returns a component builder or an already built builder
159+
*/
160+
public setChannelSelectMenuComponent(
161+
input:
162+
| APIChannelSelectComponent
163+
| ChannelSelectMenuBuilder
164+
| ((builder: ChannelSelectMenuBuilder) => ChannelSelectMenuBuilder),
165+
): this {
166+
this.data.component = resolveBuilder(input, ChannelSelectMenuBuilder);
167+
return this;
168+
}
169+
170+
/**
171+
* Sets a text input component to this label.
172+
*
173+
* @param input - A function that returns a component builder or an already built builder
174+
*/
175+
public setTextInputComponent(
176+
input: APITextInputComponent | TextInputBuilder | ((builder: TextInputBuilder) => TextInputBuilder),
177+
): this {
178+
this.data.component = resolveBuilder(input, TextInputBuilder);
179+
return this;
180+
}
181+
182+
/**
183+
* {@inheritDoc ComponentBuilder.toJSON}
184+
*/
185+
public override toJSON(): APILabelComponent {
186+
const { component, ...rest } = this.data;
187+
188+
const data = {
189+
...rest,
190+
// The label predicate validates the component.
191+
component: component?.toJSON(),
192+
};
193+
194+
labelPredicate.parse(data);
195+
196+
return data as APILabelComponent;
197+
}
198+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { Result, s } from '@sapphire/shapeshift';
2+
import { ChannelType, ComponentType, SelectMenuDefaultValueType } from 'discord-api-types/v10';
3+
import { isValidationEnabled } from '../../util/validation.js';
4+
import { customIdValidator, emojiValidator, idValidator } from '../Assertions.js';
5+
import { labelValidator } from '../textInput/Assertions.js';
6+
7+
const selectMenuBasePredicate = s.object({
8+
id: idValidator.optional(),
9+
placeholder: s.string().lengthLessThanOrEqual(150).optional(),
10+
min_values: s.number().greaterThanOrEqual(0).lessThanOrEqual(25).optional(),
11+
max_values: s.number().greaterThanOrEqual(0).lessThanOrEqual(25).optional(),
12+
custom_id: customIdValidator,
13+
disabled: s.boolean().optional(),
14+
});
15+
16+
export const selectMenuChannelPredicate = selectMenuBasePredicate
17+
.extend({
18+
type: s.literal(ComponentType.ChannelSelect),
19+
channel_types: s.nativeEnum(ChannelType).array().optional(),
20+
default_values: s
21+
.object({ id: s.string(), type: s.literal(SelectMenuDefaultValueType.Channel) })
22+
.array()
23+
.lengthLessThanOrEqual(25)
24+
.optional(),
25+
})
26+
.setValidationEnabled(isValidationEnabled);
27+
28+
export const selectMenuMentionablePredicate = selectMenuBasePredicate
29+
.extend({
30+
type: s.literal(ComponentType.MentionableSelect),
31+
default_values: s
32+
.object({
33+
id: s.string(),
34+
type: s.literal([SelectMenuDefaultValueType.Role, SelectMenuDefaultValueType.User]),
35+
})
36+
.array()
37+
.lengthLessThanOrEqual(25)
38+
.optional(),
39+
})
40+
.setValidationEnabled(isValidationEnabled);
41+
42+
export const selectMenuRolePredicate = selectMenuBasePredicate
43+
.extend({
44+
type: s.literal(ComponentType.RoleSelect),
45+
default_values: s
46+
.object({ id: s.string(), type: s.literal(SelectMenuDefaultValueType.Role) })
47+
.array()
48+
.lengthLessThanOrEqual(25)
49+
.optional(),
50+
})
51+
.setValidationEnabled(isValidationEnabled);
52+
53+
export const selectMenuUserPredicate = selectMenuBasePredicate
54+
.extend({
55+
type: s.literal(ComponentType.UserSelect),
56+
default_values: s
57+
.object({ id: s.string(), type: s.literal(SelectMenuDefaultValueType.User) })
58+
.array()
59+
.lengthLessThanOrEqual(25)
60+
.optional(),
61+
})
62+
.setValidationEnabled(isValidationEnabled);
63+
64+
export const selectMenuStringOptionPredicate = s
65+
.object({
66+
label: labelValidator,
67+
value: s.string().lengthGreaterThanOrEqual(1).lengthLessThanOrEqual(100),
68+
description: s.string().lengthGreaterThanOrEqual(1).lengthLessThanOrEqual(100).optional(),
69+
emoji: emojiValidator.optional(),
70+
default: s.boolean().optional(),
71+
})
72+
.setValidationEnabled(isValidationEnabled);
73+
74+
export const selectMenuStringPredicate = selectMenuBasePredicate
75+
.extend({
76+
type: s.literal(ComponentType.StringSelect),
77+
options: selectMenuStringOptionPredicate.array().lengthGreaterThanOrEqual(1).lengthLessThanOrEqual(25),
78+
})
79+
.reshape((value) => {
80+
if (value.min_values !== undefined && value.options.length < value.min_values) {
81+
return Result.err(new RangeError(`The number of options must be greater than or equal to min_values`));
82+
}
83+
84+
if (value.min_values !== undefined && value.max_values !== undefined && value.min_values > value.max_values) {
85+
return Result.err(
86+
new RangeError(`The maximum amount of options must be greater than or equal to the minimum amount of options`),
87+
);
88+
}
89+
90+
return Result.ok(value);
91+
})
92+
.setValidationEnabled(isValidationEnabled);

0 commit comments

Comments
 (0)