Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const selectMenuDataWithoutOptions = {
min_values: 1,
disabled: true,
placeholder: 'test',
required: false,
} as const;

const selectMenuData: APISelectMenuComponent = {
Expand Down
35 changes: 16 additions & 19 deletions packages/builders/__tests__/interactions/modal.test.ts
Original file line number Diff line number Diff line change
@@ -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();

Expand All @@ -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').addLabelComponents(label()).toJSON(),
).not.toThrowError();

expect(() =>
modal().setTitle('test').setCustomId('foobar').setLabelComponents(label()).toJSON(),
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();
});

Expand All @@ -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;
Expand All @@ -60,19 +62,14 @@ describe('Modals', () => {
modal()
.setTitle(modalData.title)
.setCustomId('custom id')
.setLabelComponents(
new LabelBuilder()
.setId(33)
.setLabel('label')
.setDescription('description')
.setTextInputComponent(new TextInputBuilder().setCustomId('custom id').setStyle(TextInputStyle.Paragraph)),
)
.addLabelComponents(
new LabelBuilder()
.setId(33)
.setLabel('label')
.setDescription('description')
.setTextInputComponent(new TextInputBuilder().setCustomId('custom id').setStyle(TextInputStyle.Paragraph)),
)
.addTextDisplayComponents((textDisplay) => textDisplay.setContent('yooooooooo'))
.toJSON(),
).toEqual(modalData);
});
Expand Down
5 changes: 5 additions & 0 deletions packages/builders/src/components/Components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
17 changes: 15 additions & 2 deletions packages/builders/src/components/label/Assertions.ts
Original file line number Diff line number Diff line change
@@ -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,
]),
});
78 changes: 75 additions & 3 deletions packages/builders/src/components/label/Label.ts
Original file line number Diff line number Diff line change
@@ -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<Omit<APILabelComponent, 'component'>> {
component?: StringSelectMenuBuilder | TextInputBuilder;
component?:
| ChannelSelectMenuBuilder
| MentionableSelectMenuBuilder
| RoleSelectMenuBuilder
| StringSelectMenuBuilder
| TextInputBuilder
| UserSelectMenuBuilder;
}

/**
Expand Down Expand Up @@ -49,7 +67,6 @@ export class LabelBuilder extends ComponentBuilder<APILabelComponent> {

this.data = {
...structuredClone(rest),
// @ts-expect-error https://github.com/discordjs/discord.js/pull/11078
component: component ? createComponentBuilder(component) : undefined,
type: ComponentType.Label,
};
Expand Down Expand Up @@ -98,6 +115,60 @@ export class LabelBuilder extends ComponentBuilder<APILabelComponent> {
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.
*
Expand All @@ -118,6 +189,7 @@ export class LabelBuilder extends ComponentBuilder<APILabelComponent> {

const data = {
...structuredClone(rest),
// The label predicate validates the component.
component: component?.toJSON(false),
};

Expand Down
13 changes: 12 additions & 1 deletion packages/builders/src/components/selectMenu/BaseSelectMenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export abstract class BaseSelectMenuBuilder<Data extends APISelectMenuComponent>
* @internal
*/
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'>
>;

/**
Expand Down Expand Up @@ -75,4 +75,15 @@ export abstract class BaseSelectMenuBuilder<Data extends APISelectMenuComponent>
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;
}
}
11 changes: 0 additions & 11 deletions packages/builders/src/components/selectMenu/StringSelectMenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,17 +147,6 @@ export class StringSelectMenuBuilder extends BaseSelectMenuBuilder<APIStringSele
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}
*/
Expand Down
2 changes: 2 additions & 0 deletions packages/builders/src/interactions/modals/Assertions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { ComponentType } from 'discord-api-types/v10';
import { z } from 'zod';
import { customIdPredicate } from '../../Assertions.js';
import { labelPredicate } from '../../components/label/Assertions.js';
import { textDisplayPredicate } from '../../components/v2/Assertions.js';

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

Expand All @@ -18,6 +19,7 @@ export const modalPredicate = z.object({
.length(1),
}),
labelPredicate,
textDisplayPredicate,
])
.array()
.min(1)
Expand Down
Loading