refactor: builders (#10448)

BREAKING CHANGE: formatters export removed (prev. deprecated)
BREAKING CHANGE: `SelectMenuBuilder` and `SelectMenuOptionBuilder` have been removed (prev. deprecated)
BREAKING CHANGE: `EmbedBuilder` no longer takes camalCase options
BREAKING CHANGE: `ActionRowBuilder` now has specialized `[add/set]X` methods as opposed to the current `[add/set]Components`
BREAKING CHANGE: Removed `equals` methods
BREAKING CHANGE: Sapphire -> zod for validation
BREAKING CHANGE: Removed the ability to pass `null`/`undefined` to clear fields, use `clearX()` instead
BREAKING CHANGE: Renamed all "slash command" symbols to instead use "chat input command"
BREAKING CHANGE: Removed `ContextMenuCommandBuilder` in favor of `MessageCommandBuilder` and `UserCommandBuilder`
BREAKING CHANGE: Removed support for passing the "string key"s of enums
BREAKING CHANGE: Removed `Button` class in favor for specialized classes depending on the style
BREAKING CHANGE: Removed nested `addX` styled-methods in favor of plural `addXs`

Co-authored-by: Vlad Frangu <me@vladfrangu.dev>
Co-authored-by: Almeida <github@almeidx.dev>
This commit is contained in:
Denis Cristea
2024-10-01 19:11:56 +03:00
committed by GitHub
parent c633d5c7f6
commit ab32f26cbb
91 changed files with 3772 additions and 3824 deletions

View File

@@ -1,68 +1,60 @@
/* eslint-disable jsdoc/check-param-names */
import {
type APIActionRowComponent,
ComponentType,
type APIMessageActionRowComponent,
type APIModalActionRowComponent,
type APIActionRowComponentTypes,
import type {
APITextInputComponent,
APIActionRowComponent,
APIActionRowComponentTypes,
APIChannelSelectComponent,
APIMentionableSelectComponent,
APIRoleSelectComponent,
APIStringSelectComponent,
APIUserSelectComponent,
APIButtonComponentWithCustomId,
APIButtonComponentWithSKUId,
APIButtonComponentWithURL,
} from 'discord-api-types/v10';
import { ComponentType } from 'discord-api-types/v10';
import { normalizeArray, type RestOrArray } from '../util/normalizeArray.js';
import { resolveBuilder } from '../util/resolveBuilder.js';
import { isValidationEnabled } from '../util/validation.js';
import { actionRowPredicate } from './Assertions.js';
import { ComponentBuilder } from './Component.js';
import type { AnyActionRowComponentBuilder } from './Components.js';
import { createComponentBuilder } from './Components.js';
import type { ButtonBuilder } from './button/Button.js';
import type { ChannelSelectMenuBuilder } from './selectMenu/ChannelSelectMenu.js';
import type { MentionableSelectMenuBuilder } from './selectMenu/MentionableSelectMenu.js';
import type { RoleSelectMenuBuilder } from './selectMenu/RoleSelectMenu.js';
import type { StringSelectMenuBuilder } from './selectMenu/StringSelectMenu.js';
import type { UserSelectMenuBuilder } from './selectMenu/UserSelectMenu.js';
import type { TextInputBuilder } from './textInput/TextInput.js';
import {
DangerButtonBuilder,
PrimaryButtonBuilder,
SecondaryButtonBuilder,
SuccessButtonBuilder,
} from './button/CustomIdButton.js';
import { LinkButtonBuilder } from './button/LinkButton.js';
import { PremiumButtonBuilder } from './button/PremiumButton.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';
/**
* The builders that may be used for messages.
*/
export type MessageComponentBuilder =
| ActionRowBuilder<MessageActionRowComponentBuilder>
| MessageActionRowComponentBuilder;
/**
* The builders that may be used for modals.
*/
export type ModalComponentBuilder = ActionRowBuilder<ModalActionRowComponentBuilder> | ModalActionRowComponentBuilder;
/**
* The builders that may be used within an action row for messages.
*/
export type MessageActionRowComponentBuilder =
| ButtonBuilder
| ChannelSelectMenuBuilder
| MentionableSelectMenuBuilder
| RoleSelectMenuBuilder
| StringSelectMenuBuilder
| UserSelectMenuBuilder;
/**
* The builders that may be used within an action row for modals.
*/
export type ModalActionRowComponentBuilder = TextInputBuilder;
/**
* Any builder.
*/
export type AnyComponentBuilder = MessageActionRowComponentBuilder | ModalActionRowComponentBuilder;
export interface ActionRowBuilderData
extends Partial<Omit<APIActionRowComponent<APIActionRowComponentTypes>, 'components'>> {
components: AnyActionRowComponentBuilder[];
}
/**
* A builder that creates API-compatible JSON data for action rows.
*
* @typeParam ComponentType - The types of components this action row holds
*/
export class ActionRowBuilder<ComponentType extends AnyComponentBuilder> extends ComponentBuilder<
APIActionRowComponent<APIMessageActionRowComponent | APIModalActionRowComponent>
> {
export class ActionRowBuilder extends ComponentBuilder<APIActionRowComponent<APIActionRowComponentTypes>> {
private readonly data: ActionRowBuilderData;
/**
* The components within this action row.
*/
public readonly components: ComponentType[];
public get components(): readonly AnyActionRowComponentBuilder[] {
return this.data.components;
}
/**
* Creates a new action row from API data.
@@ -98,38 +90,256 @@ export class ActionRowBuilder<ComponentType extends AnyComponentBuilder> extends
* .addComponents(button2, button3);
* ```
*/
public constructor({ components, ...data }: Partial<APIActionRowComponent<APIActionRowComponentTypes>> = {}) {
super({ type: ComponentType.ActionRow, ...data });
this.components = (components?.map((component) => createComponentBuilder(component)) ?? []) as ComponentType[];
public constructor({ components = [], ...data }: Partial<APIActionRowComponent<APIActionRowComponentTypes>> = {}) {
super();
this.data = {
...structuredClone(data),
type: ComponentType.ActionRow,
components: components.map((component) => createComponentBuilder(component)),
};
}
/**
* Adds components to this action row.
* Adds primary button components to this action row.
*
* @param components - The components to add
* @param input - The buttons to add
*/
public addComponents(...components: RestOrArray<ComponentType>) {
this.components.push(...normalizeArray(components));
public addPrimaryButtonComponents(
...input: RestOrArray<
APIButtonComponentWithCustomId | PrimaryButtonBuilder | ((builder: PrimaryButtonBuilder) => PrimaryButtonBuilder)
>
): this {
const normalized = normalizeArray(input);
const resolved = normalized.map((component) => resolveBuilder(component, PrimaryButtonBuilder));
this.data.components.push(...resolved);
return this;
}
/**
* Sets components for this action row.
* Adds secondary button components to this action row.
*
* @param components - The components to set
* @param input - The buttons to add
*/
public setComponents(...components: RestOrArray<ComponentType>) {
this.components.splice(0, this.components.length, ...normalizeArray(components));
public addSecondaryButtonComponents(
...input: RestOrArray<
| APIButtonComponentWithCustomId
| SecondaryButtonBuilder
| ((builder: SecondaryButtonBuilder) => SecondaryButtonBuilder)
>
): this {
const normalized = normalizeArray(input);
const resolved = normalized.map((component) => resolveBuilder(component, SecondaryButtonBuilder));
this.data.components.push(...resolved);
return this;
}
/**
* Adds success button components to this action row.
*
* @param input - The buttons to add
*/
public addSuccessButtonComponents(
...input: RestOrArray<
APIButtonComponentWithCustomId | SuccessButtonBuilder | ((builder: SuccessButtonBuilder) => SuccessButtonBuilder)
>
): this {
const normalized = normalizeArray(input);
const resolved = normalized.map((component) => resolveBuilder(component, SuccessButtonBuilder));
this.data.components.push(...resolved);
return this;
}
/**
* Adds danger button components to this action row.
*/
public addDangerButtonComponents(
...input: RestOrArray<
APIButtonComponentWithCustomId | DangerButtonBuilder | ((builder: DangerButtonBuilder) => DangerButtonBuilder)
>
): this {
const normalized = normalizeArray(input);
const resolved = normalized.map((component) => resolveBuilder(component, DangerButtonBuilder));
this.data.components.push(...resolved);
return this;
}
/**
* Generically add any type of component to this action row, only takes in an instance of a component builder.
*/
public addComponents(...input: RestOrArray<AnyActionRowComponentBuilder>): this {
const normalized = normalizeArray(input);
this.data.components.push(...normalized);
return this;
}
/**
* Adds SKU id button components to this action row.
*
* @param input - The buttons to add
*/
public addPremiumButtonComponents(
...input: RestOrArray<
APIButtonComponentWithSKUId | PremiumButtonBuilder | ((builder: PremiumButtonBuilder) => PremiumButtonBuilder)
>
): this {
const normalized = normalizeArray(input);
const resolved = normalized.map((component) => resolveBuilder(component, PremiumButtonBuilder));
this.data.components.push(...resolved);
return this;
}
/**
* Adds URL button components to this action row.
*
* @param input - The buttons to add
*/
public addLinkButtonComponents(
...input: RestOrArray<
APIButtonComponentWithURL | LinkButtonBuilder | ((builder: LinkButtonBuilder) => LinkButtonBuilder)
>
): this {
const normalized = normalizeArray(input);
const resolved = normalized.map((component) => resolveBuilder(component, LinkButtonBuilder));
this.data.components.push(...resolved);
return this;
}
/**
* Adds a channel select menu component to this action row.
*
* @param input - A function that returns a component builder or an already built builder
*/
public addChannelSelectMenuComponent(
input:
| APIChannelSelectComponent
| ChannelSelectMenuBuilder
| ((builder: ChannelSelectMenuBuilder) => ChannelSelectMenuBuilder),
): this {
this.data.components.push(resolveBuilder(input, ChannelSelectMenuBuilder));
return this;
}
/**
* Adds a mentionable select menu component to this action row.
*
* @param input - A function that returns a component builder or an already built builder
*/
public addMentionableSelectMenuComponent(
input:
| APIMentionableSelectComponent
| MentionableSelectMenuBuilder
| ((builder: MentionableSelectMenuBuilder) => MentionableSelectMenuBuilder),
): this {
this.data.components.push(resolveBuilder(input, MentionableSelectMenuBuilder));
return this;
}
/**
* Adds a role select menu component to this action row.
*
* @param input - A function that returns a component builder or an already built builder
*/
public addRoleSelectMenuComponent(
input: APIRoleSelectComponent | RoleSelectMenuBuilder | ((builder: RoleSelectMenuBuilder) => RoleSelectMenuBuilder),
): this {
this.data.components.push(resolveBuilder(input, RoleSelectMenuBuilder));
return this;
}
/**
* Adds a string select menu component to this action row.
*
* @param input - A function that returns a component builder or an already built builder
*/
public addStringSelectMenuComponent(
input:
| APIStringSelectComponent
| StringSelectMenuBuilder
| ((builder: StringSelectMenuBuilder) => StringSelectMenuBuilder),
): this {
this.data.components.push(resolveBuilder(input, StringSelectMenuBuilder));
return this;
}
/**
* Adds a user select menu component to this action row.
*
* @param input - A function that returns a component builder or an already built builder
*/
public addUserSelectMenuComponent(
input: APIUserSelectComponent | UserSelectMenuBuilder | ((builder: UserSelectMenuBuilder) => UserSelectMenuBuilder),
): this {
this.data.components.push(resolveBuilder(input, UserSelectMenuBuilder));
return this;
}
/**
* Adds a text input component to this action row.
*
* @param input - A function that returns a component builder or an already built builder
*/
public addTextInputComponent(
input: APITextInputComponent | TextInputBuilder | ((builder: TextInputBuilder) => TextInputBuilder),
): this {
this.data.components.push(resolveBuilder(input, TextInputBuilder));
return this;
}
/**
* Removes, replaces, or inserts components for this action row.
*
* @remarks
* This method behaves similarly
* to {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/splice | Array.prototype.splice()}.
*
* It's useful for modifying and adjusting order of the already-existing components of an action row.
* @example
* Remove the first component:
* ```ts
* actionRow.spliceComponents(0, 1);
* ```
* @example
* Remove the first n components:
* ```ts
* const n = 4;
* actionRow.spliceComponents(0, n);
* ```
* @example
* Remove the last component:
* ```ts
* actionRow.spliceComponents(-1, 1);
* ```
* @param index - The index to start at
* @param deleteCount - The number of components to remove
* @param components - The replacing component objects
*/
public spliceComponents(index: number, deleteCount: number, ...components: AnyActionRowComponentBuilder[]): this {
this.data.components.splice(index, deleteCount, ...components);
return this;
}
/**
* {@inheritDoc ComponentBuilder.toJSON}
*/
public toJSON(): APIActionRowComponent<ReturnType<ComponentType['toJSON']>> {
return {
...this.data,
components: this.components.map((component) => component.toJSON()),
} as APIActionRowComponent<ReturnType<ComponentType['toJSON']>>;
public override toJSON(validationOverride?: boolean): APIActionRowComponent<APIActionRowComponentTypes> {
const { components, ...rest } = this.data;
const data = {
...structuredClone(rest),
components: components.map((component) => component.toJSON(validationOverride)),
};
if (validationOverride ?? isValidationEnabled()) {
actionRowPredicate.parse(data);
}
return data as APIActionRowComponent<APIActionRowComponentTypes>;
}
}

View File

@@ -1,127 +1,168 @@
import { s } from '@sapphire/shapeshift';
import { ButtonStyle, ChannelType, type APIMessageComponentEmoji } from 'discord-api-types/v10';
import { isValidationEnabled } from '../util/validation.js';
import { StringSelectMenuOptionBuilder } from './selectMenu/StringSelectMenuOption.js';
import { ButtonStyle, ChannelType, ComponentType, SelectMenuDefaultValueType } from 'discord-api-types/v10';
import { z } from 'zod';
import { customIdPredicate, refineURLPredicate } from '../Assertions.js';
export const customIdValidator = s
.string()
.lengthGreaterThanOrEqual(1)
.lengthLessThanOrEqual(100)
.setValidationEnabled(isValidationEnabled);
const labelPredicate = z.string().min(1).max(80);
export const emojiValidator = s
export const emojiPredicate = z
.object({
id: s.string(),
name: s.string(),
animated: s.boolean(),
id: z.string().optional(),
name: z.string().min(2).max(32).optional(),
animated: z.boolean().optional(),
})
.partial()
.strict()
.setValidationEnabled(isValidationEnabled);
.refine((data) => data.id !== undefined || data.name !== undefined, {
message: "Either 'id' or 'name' must be provided",
});
export const disabledValidator = s.boolean();
const buttonPredicateBase = z.object({
type: z.literal(ComponentType.Button),
disabled: z.boolean().optional(),
});
export const buttonLabelValidator = s
.string()
.lengthGreaterThanOrEqual(1)
.lengthLessThanOrEqual(80)
.setValidationEnabled(isValidationEnabled);
const buttonCustomIdPredicateBase = buttonPredicateBase.extend({
custom_id: customIdPredicate,
emoji: emojiPredicate.optional(),
label: labelPredicate,
});
export const buttonStyleValidator = s.nativeEnum(ButtonStyle);
const buttonPrimaryPredicate = buttonCustomIdPredicateBase.extend({ style: z.literal(ButtonStyle.Primary) }).strict();
const buttonSecondaryPredicate = buttonCustomIdPredicateBase
.extend({ style: z.literal(ButtonStyle.Secondary) })
.strict();
const buttonSuccessPredicate = buttonCustomIdPredicateBase.extend({ style: z.literal(ButtonStyle.Success) }).strict();
const buttonDangerPredicate = buttonCustomIdPredicateBase.extend({ style: z.literal(ButtonStyle.Danger) }).strict();
export const placeholderValidator = s.string().lengthLessThanOrEqual(150).setValidationEnabled(isValidationEnabled);
export const minMaxValidator = s
.number()
.int()
.greaterThanOrEqual(0)
.lessThanOrEqual(25)
.setValidationEnabled(isValidationEnabled);
export const labelValueDescriptionValidator = s
.string()
.lengthGreaterThanOrEqual(1)
.lengthLessThanOrEqual(100)
.setValidationEnabled(isValidationEnabled);
export const jsonOptionValidator = s
.object({
label: labelValueDescriptionValidator,
value: labelValueDescriptionValidator,
description: labelValueDescriptionValidator.optional(),
emoji: emojiValidator.optional(),
default: s.boolean().optional(),
const buttonLinkPredicate = buttonPredicateBase
.extend({
style: z.literal(ButtonStyle.Link),
url: z
.string()
.url()
.refine(refineURLPredicate(['http:', 'https:', 'discord:'])),
emoji: emojiPredicate.optional(),
label: labelPredicate,
})
.setValidationEnabled(isValidationEnabled);
.strict();
export const optionValidator = s.instance(StringSelectMenuOptionBuilder).setValidationEnabled(isValidationEnabled);
export const optionsValidator = optionValidator
.array()
.lengthGreaterThanOrEqual(0)
.setValidationEnabled(isValidationEnabled);
export const optionsLengthValidator = s
.number()
.int()
.greaterThanOrEqual(0)
.lessThanOrEqual(25)
.setValidationEnabled(isValidationEnabled);
export function validateRequiredSelectMenuParameters(options: StringSelectMenuOptionBuilder[], customId?: string) {
customIdValidator.parse(customId);
optionsValidator.parse(options);
}
export const defaultValidator = s.boolean();
export function validateRequiredSelectMenuOptionParameters(label?: string, value?: string) {
labelValueDescriptionValidator.parse(label);
labelValueDescriptionValidator.parse(value);
}
export const channelTypesValidator = s.nativeEnum(ChannelType).array().setValidationEnabled(isValidationEnabled);
export const urlValidator = s
.string()
.url({
allowedProtocols: ['http:', 'https:', 'discord:'],
const buttonPremiumPredicate = buttonPredicateBase
.extend({
style: z.literal(ButtonStyle.Premium),
sku_id: z.string(),
})
.setValidationEnabled(isValidationEnabled);
.strict();
export function validateRequiredButtonParameters(
style?: ButtonStyle,
label?: string,
emoji?: APIMessageComponentEmoji,
customId?: string,
skuId?: string,
url?: string,
) {
if (style === ButtonStyle.Premium) {
if (!skuId) {
throw new RangeError('Premium buttons must have an SKU id.');
export const buttonPredicate = z.discriminatedUnion('style', [
buttonLinkPredicate,
buttonPrimaryPredicate,
buttonSecondaryPredicate,
buttonSuccessPredicate,
buttonDangerPredicate,
buttonPremiumPredicate,
]);
const selectMenuBasePredicate = z.object({
placeholder: z.string().max(150).optional(),
min_values: z.number().min(0).max(25).optional(),
max_values: z.number().min(0).max(25).optional(),
custom_id: customIdPredicate,
disabled: z.boolean().optional(),
});
export const selectMenuChannelPredicate = selectMenuBasePredicate.extend({
type: z.literal(ComponentType.ChannelSelect),
channel_types: z.nativeEnum(ChannelType).array().optional(),
default_values: z
.object({ id: z.string(), type: z.literal(SelectMenuDefaultValueType.Channel) })
.array()
.max(25)
.optional(),
});
export const selectMenuMentionablePredicate = selectMenuBasePredicate.extend({
type: z.literal(ComponentType.MentionableSelect),
default_values: z
.object({
id: z.string(),
type: z.union([z.literal(SelectMenuDefaultValueType.Role), z.literal(SelectMenuDefaultValueType.User)]),
})
.array()
.max(25)
.optional(),
});
export const selectMenuRolePredicate = selectMenuBasePredicate.extend({
type: z.literal(ComponentType.RoleSelect),
default_values: z
.object({ id: z.string(), type: z.literal(SelectMenuDefaultValueType.Role) })
.array()
.max(25)
.optional(),
});
export const selectMenuStringOptionPredicate = z.object({
label: labelPredicate,
value: z.string().min(1).max(100),
description: z.string().min(1).max(100).optional(),
emoji: emojiPredicate.optional(),
default: z.boolean().optional(),
});
export const selectMenuStringPredicate = selectMenuBasePredicate
.extend({
type: z.literal(ComponentType.StringSelect),
options: selectMenuStringOptionPredicate.array().min(1).max(25),
})
.superRefine((menu, ctx) => {
const addIssue = (name: string, minimum: number) =>
ctx.addIssue({
code: 'too_small',
message: `The number of options must be greater than or equal to ${name}`,
inclusive: true,
minimum,
type: 'number',
path: ['options'],
});
if (menu.max_values !== undefined && menu.options.length < menu.max_values) {
addIssue('max_values', menu.max_values);
}
if (customId || label || url || emoji) {
throw new RangeError('Premium buttons cannot have a custom id, label, URL, or emoji.');
}
} else {
if (skuId) {
throw new RangeError('Non-premium buttons must not have an SKU id.');
if (menu.min_values !== undefined && menu.options.length < menu.min_values) {
addIssue('min_values', menu.min_values);
}
});
if (url && customId) {
throw new RangeError('URL and custom id are mutually exclusive.');
}
export const selectMenuUserPredicate = selectMenuBasePredicate.extend({
type: z.literal(ComponentType.UserSelect),
default_values: z
.object({ id: z.string(), type: z.literal(SelectMenuDefaultValueType.User) })
.array()
.max(25)
.optional(),
});
if (!label && !emoji) {
throw new RangeError('Non-premium buttons must have a label and/or an emoji.');
}
if (style === ButtonStyle.Link) {
if (!url) {
throw new RangeError('Link buttons must have a URL.');
}
} else if (url) {
throw new RangeError('Non-premium and non-link buttons cannot have a URL.');
}
}
}
export const actionRowPredicate = z.object({
type: z.literal(ComponentType.ActionRow),
components: z.union([
z
.object({ type: z.literal(ComponentType.Button) })
.array()
.min(1)
.max(5),
z
.object({
type: z.union([
z.literal(ComponentType.ChannelSelect),
z.literal(ComponentType.MentionableSelect),
z.literal(ComponentType.RoleSelect),
z.literal(ComponentType.StringSelect),
z.literal(ComponentType.UserSelect),
// And this!
z.literal(ComponentType.TextInput),
]),
})
.array()
.length(1),
]),
});

View File

@@ -1,10 +1,5 @@
import type { JSONEncodable } from '@discordjs/util';
import type {
APIActionRowComponent,
APIActionRowComponentTypes,
APIBaseComponent,
ComponentType,
} from 'discord-api-types/v10';
import type { APIActionRowComponent, APIActionRowComponentTypes } from 'discord-api-types/v10';
/**
* Any action row component data represented as an object.
@@ -14,32 +9,15 @@ export type AnyAPIActionRowComponent = APIActionRowComponent<APIActionRowCompone
/**
* The base component builder that contains common symbols for all sorts of components.
*
* @typeParam DataType - The type of internal API data that is stored within the component
* @typeParam Component - The type of API data that is stored within the builder
*/
export abstract class ComponentBuilder<
DataType extends Partial<APIBaseComponent<ComponentType>> = APIBaseComponent<ComponentType>,
> implements JSONEncodable<AnyAPIActionRowComponent>
{
/**
* The API data associated with this component.
*/
public readonly data: Partial<DataType>;
export abstract class ComponentBuilder<Component extends AnyAPIActionRowComponent> implements JSONEncodable<Component> {
/**
* Serializes this builder to API-compatible JSON data.
*
* @remarks
* This method runs validations on the data before serializing it.
* As such, it may throw an error if the data is invalid.
*/
public abstract toJSON(): AnyAPIActionRowComponent;
/**
* Constructs a new kind of component.
* Note that by disabling validation, there is no guarantee that the resulting object will be valid.
*
* @param data - The data to construct a component out of
* @param validationOverride - Force validation to run/not run regardless of your global preference
*/
public constructor(data: Partial<DataType>) {
this.data = data;
}
public abstract toJSON(validationOverride?: boolean): Component;
}

View File

@@ -1,12 +1,17 @@
import { ComponentType, type APIMessageComponent, type APIModalComponent } from 'discord-api-types/v10';
import {
ActionRowBuilder,
type AnyComponentBuilder,
type MessageComponentBuilder,
type ModalComponentBuilder,
} from './ActionRow.js';
import type { APIButtonComponent, APIMessageComponent, APIModalComponent } from 'discord-api-types/v10';
import { ButtonStyle, ComponentType } from 'discord-api-types/v10';
import { ActionRowBuilder } from './ActionRow.js';
import type { AnyAPIActionRowComponent } from './Component.js';
import { ComponentBuilder } from './Component.js';
import { ButtonBuilder } from './button/Button.js';
import type { BaseButtonBuilder } from './button/Button.js';
import {
DangerButtonBuilder,
PrimaryButtonBuilder,
SecondaryButtonBuilder,
SuccessButtonBuilder,
} from './button/CustomIdButton.js';
import { LinkButtonBuilder } from './button/LinkButton.js';
import { PremiumButtonBuilder } from './button/PremiumButton.js';
import { ChannelSelectMenuBuilder } from './selectMenu/ChannelSelectMenu.js';
import { MentionableSelectMenuBuilder } from './selectMenu/MentionableSelectMenu.js';
import { RoleSelectMenuBuilder } from './selectMenu/RoleSelectMenu.js';
@@ -14,6 +19,48 @@ import { StringSelectMenuBuilder } from './selectMenu/StringSelectMenu.js';
import { UserSelectMenuBuilder } from './selectMenu/UserSelectMenu.js';
import { TextInputBuilder } from './textInput/TextInput.js';
/**
* The builders that may be used for messages.
*/
export type MessageComponentBuilder = ActionRowBuilder | MessageActionRowComponentBuilder;
/**
* The builders that may be used for modals.
*/
export type ModalComponentBuilder = ActionRowBuilder | ModalActionRowComponentBuilder;
/**
* Any button builder
*/
export type ButtonBuilder =
| DangerButtonBuilder
| LinkButtonBuilder
| PremiumButtonBuilder
| PrimaryButtonBuilder
| SecondaryButtonBuilder
| SuccessButtonBuilder;
/**
* The builders that may be used within an action row for messages.
*/
export type MessageActionRowComponentBuilder =
| ButtonBuilder
| ChannelSelectMenuBuilder
| MentionableSelectMenuBuilder
| RoleSelectMenuBuilder
| StringSelectMenuBuilder
| UserSelectMenuBuilder;
/**
* The builders that may be used within an action row for modals.
*/
export type ModalActionRowComponentBuilder = TextInputBuilder;
/**
* Any action row component builder.
*/
export type AnyActionRowComponentBuilder = MessageActionRowComponentBuilder | ModalActionRowComponentBuilder;
/**
* Components here are mapped to their respective builder.
*/
@@ -21,9 +68,9 @@ export interface MappedComponentTypes {
/**
* The action row component type is associated with an {@link ActionRowBuilder}.
*/
[ComponentType.ActionRow]: ActionRowBuilder<AnyComponentBuilder>;
[ComponentType.ActionRow]: ActionRowBuilder;
/**
* The button component type is associated with a {@link ButtonBuilder}.
* The button component type is associated with a {@link BaseButtonBuilder}.
*/
[ComponentType.Button]: ButtonBuilder;
/**
@@ -75,7 +122,7 @@ export function createComponentBuilder<ComponentBuilder extends MessageComponent
export function createComponentBuilder(
data: APIMessageComponent | APIModalComponent | MessageComponentBuilder,
): ComponentBuilder {
): ComponentBuilder<AnyAPIActionRowComponent> {
if (data instanceof ComponentBuilder) {
return data;
}
@@ -84,7 +131,7 @@ export function createComponentBuilder(
case ComponentType.ActionRow:
return new ActionRowBuilder(data);
case ComponentType.Button:
return new ButtonBuilder(data);
return createButtonBuilder(data);
case ComponentType.StringSelect:
return new StringSelectMenuBuilder(data);
case ComponentType.TextInput:
@@ -102,3 +149,23 @@ export function createComponentBuilder(
throw new Error(`Cannot properly serialize component type: ${data.type}`);
}
}
function createButtonBuilder(data: APIButtonComponent): ButtonBuilder {
switch (data.style) {
case ButtonStyle.Primary:
return new PrimaryButtonBuilder(data);
case ButtonStyle.Secondary:
return new SecondaryButtonBuilder(data);
case ButtonStyle.Success:
return new SuccessButtonBuilder(data);
case ButtonStyle.Danger:
return new DangerButtonBuilder(data);
case ButtonStyle.Link:
return new LinkButtonBuilder(data);
case ButtonStyle.Premium:
return new PremiumButtonBuilder(data);
default:
// @ts-expect-error This case can still occur if we get a newer unsupported button style
throw new Error(`Cannot properly serialize button with style: ${data.style}`);
}
}

View File

@@ -1,115 +1,13 @@
import {
ComponentType,
type APIButtonComponent,
type APIButtonComponentWithCustomId,
type APIButtonComponentWithSKUId,
type APIButtonComponentWithURL,
type APIMessageComponentEmoji,
type ButtonStyle,
type Snowflake,
} from 'discord-api-types/v10';
import {
buttonLabelValidator,
buttonStyleValidator,
customIdValidator,
disabledValidator,
emojiValidator,
urlValidator,
validateRequiredButtonParameters,
} from '../Assertions.js';
import type { APIButtonComponent } from 'discord-api-types/v10';
import { isValidationEnabled } from '../../util/validation.js';
import { buttonPredicate } from '../Assertions.js';
import { ComponentBuilder } from '../Component.js';
/**
* A builder that creates API-compatible JSON data for buttons.
*/
export class ButtonBuilder extends ComponentBuilder<APIButtonComponent> {
/**
* Creates a new button from API data.
*
* @param data - The API data to create this button with
* @example
* Creating a button from an API data object:
* ```ts
* const button = new ButtonBuilder({
* custom_id: 'a cool button',
* style: ButtonStyle.Primary,
* label: 'Click Me',
* emoji: {
* name: 'smile',
* id: '123456789012345678',
* },
* });
* ```
* @example
* Creating a button using setters and API data:
* ```ts
* const button = new ButtonBuilder({
* style: ButtonStyle.Secondary,
* label: 'Click Me',
* })
* .setEmoji({ name: '🙂' })
* .setCustomId('another cool button');
* ```
*/
public constructor(data?: Partial<APIButtonComponent>) {
super({ type: ComponentType.Button, ...data });
}
/**
* Sets the style of this button.
*
* @param style - The style to use
*/
public setStyle(style: ButtonStyle) {
this.data.style = buttonStyleValidator.parse(style);
return this;
}
/**
* Sets the URL for this button.
*
* @remarks
* This method is only available to buttons using the `Link` button style.
* Only three types of URL schemes are currently supported: `https://`, `http://`, and `discord://`.
* @param url - The URL to use
*/
public setURL(url: string) {
(this.data as APIButtonComponentWithURL).url = urlValidator.parse(url);
return this;
}
/**
* Sets the custom id for this button.
*
* @remarks
* This method is only applicable to buttons that are not using the `Link` button style.
* @param customId - The custom id to use
*/
public setCustomId(customId: string) {
(this.data as APIButtonComponentWithCustomId).custom_id = customIdValidator.parse(customId);
return this;
}
/**
* Sets the SKU id that represents a purchasable SKU for this button.
*
* @remarks Only available when using premium-style buttons.
* @param skuId - The SKU id to use
*/
public setSKUId(skuId: Snowflake) {
(this.data as APIButtonComponentWithSKUId).sku_id = skuId;
return this;
}
/**
* Sets the emoji to display on this button.
*
* @param emoji - The emoji to use
*/
public setEmoji(emoji: APIMessageComponentEmoji) {
(this.data as Exclude<APIButtonComponent, APIButtonComponentWithSKUId>).emoji = emojiValidator.parse(emoji);
return this;
}
export abstract class BaseButtonBuilder<ButtonData extends APIButtonComponent> extends ComponentBuilder<ButtonData> {
protected declare readonly data: Partial<ButtonData>;
/**
* Sets whether this button is disabled.
@@ -117,35 +15,20 @@ export class ButtonBuilder extends ComponentBuilder<APIButtonComponent> {
* @param disabled - Whether to disable this button
*/
public setDisabled(disabled = true) {
this.data.disabled = disabledValidator.parse(disabled);
return this;
}
/**
* Sets the label for this button.
*
* @param label - The label to use
*/
public setLabel(label: string) {
(this.data as Exclude<APIButtonComponent, APIButtonComponentWithSKUId>).label = buttonLabelValidator.parse(label);
this.data.disabled = disabled;
return this;
}
/**
* {@inheritDoc ComponentBuilder.toJSON}
*/
public toJSON(): APIButtonComponent {
validateRequiredButtonParameters(
this.data.style,
(this.data as Exclude<APIButtonComponent, APIButtonComponentWithSKUId>).label,
(this.data as Exclude<APIButtonComponent, APIButtonComponentWithSKUId>).emoji,
(this.data as APIButtonComponentWithCustomId).custom_id,
(this.data as APIButtonComponentWithSKUId).sku_id,
(this.data as APIButtonComponentWithURL).url,
);
public override toJSON(validationOverride?: boolean): ButtonData {
const clone = structuredClone(this.data);
return {
...this.data,
} as APIButtonComponent;
if (validationOverride ?? isValidationEnabled()) {
buttonPredicate.parse(clone);
}
return clone as ButtonData;
}
}

View File

@@ -0,0 +1,69 @@
import { ButtonStyle, ComponentType, type APIButtonComponentWithCustomId } from 'discord-api-types/v10';
import { Mixin } from 'ts-mixer';
import { BaseButtonBuilder } from './Button.js';
import { EmojiOrLabelButtonMixin } from './mixins/EmojiOrLabelButtonMixin.js';
export type CustomIdButtonStyle = APIButtonComponentWithCustomId['style'];
/**
* A builder that creates API-compatible JSON data for buttons with custom IDs.
*/
export abstract class CustomIdButtonBuilder extends Mixin(
BaseButtonBuilder<APIButtonComponentWithCustomId>,
EmojiOrLabelButtonMixin,
) {
protected override readonly data: Partial<APIButtonComponentWithCustomId>;
protected constructor(data: Partial<APIButtonComponentWithCustomId> = {}) {
super();
this.data = { ...structuredClone(data), type: ComponentType.Button };
}
/**
* Sets the custom id for this button.
*
* @remarks
* This method is only applicable to buttons that are not using the `Link` button style.
* @param customId - The custom id to use
*/
public setCustomId(customId: string) {
this.data.custom_id = customId;
return this;
}
}
/**
* A builder that creates API-compatible JSON data for buttons with custom IDs (using the primary style).
*/
export class PrimaryButtonBuilder extends CustomIdButtonBuilder {
public constructor(data: Partial<APIButtonComponentWithCustomId> = {}) {
super({ ...data, style: ButtonStyle.Primary });
}
}
/**
* A builder that creates API-compatible JSON data for buttons with custom IDs (using the secondary style).
*/
export class SecondaryButtonBuilder extends CustomIdButtonBuilder {
public constructor(data: Partial<APIButtonComponentWithCustomId> = {}) {
super({ ...data, style: ButtonStyle.Secondary });
}
}
/**
* A builder that creates API-compatible JSON data for buttons with custom IDs (using the success style).
*/
export class SuccessButtonBuilder extends CustomIdButtonBuilder {
public constructor(data: Partial<APIButtonComponentWithCustomId> = {}) {
super({ ...data, style: ButtonStyle.Success });
}
}
/**
* A builder that creates API-compatible JSON data for buttons with custom IDs (using the danger style).
*/
export class DangerButtonBuilder extends CustomIdButtonBuilder {
public constructor(data: Partial<APIButtonComponentWithCustomId> = {}) {
super({ ...data, style: ButtonStyle.Danger });
}
}

View File

@@ -0,0 +1,34 @@
import {
ButtonStyle,
ComponentType,
type APIButtonComponent,
type APIButtonComponentWithURL,
} from 'discord-api-types/v10';
import { Mixin } from 'ts-mixer';
import { BaseButtonBuilder } from './Button.js';
import { EmojiOrLabelButtonMixin } from './mixins/EmojiOrLabelButtonMixin.js';
/**
* A builder that creates API-compatible JSON data for buttons with links.
*/
export class LinkButtonBuilder extends Mixin(BaseButtonBuilder<APIButtonComponentWithURL>, EmojiOrLabelButtonMixin) {
protected override readonly data: Partial<APIButtonComponentWithURL>;
public constructor(data: Partial<APIButtonComponent> = {}) {
super();
this.data = { ...structuredClone(data), type: ComponentType.Button, style: ButtonStyle.Link };
}
/**
* Sets the URL for this button.
*
* @remarks
* This method is only available to buttons using the `Link` button style.
* Only three types of URL schemes are currently supported: `https://`, `http://`, and `discord://`.
* @param url - The URL to use
*/
public setURL(url: string) {
this.data.url = url;
return this;
}
}

View File

@@ -0,0 +1,26 @@
import type { APIButtonComponentWithSKUId, Snowflake } from 'discord-api-types/v10';
import { ButtonStyle, ComponentType } from 'discord-api-types/v10';
import { BaseButtonBuilder } from './Button.js';
/**
* A builder that creates API-compatible JSON data for premium buttons.
*/
export class PremiumButtonBuilder extends BaseButtonBuilder<APIButtonComponentWithSKUId> {
protected override readonly data: Partial<APIButtonComponentWithSKUId>;
public constructor(data: Partial<APIButtonComponentWithSKUId> = {}) {
super();
this.data = { ...structuredClone(data), type: ComponentType.Button, style: ButtonStyle.Premium };
}
/**
* Sets the SKU id that represents a purchasable SKU for this button.
*
* @remarks Only available when using premium-style buttons.
* @param skuId - The SKU id to use
*/
public setSKUId(skuId: Snowflake) {
this.data.sku_id = skuId;
return this;
}
}

View File

@@ -0,0 +1,44 @@
import type { APIButtonComponent, APIButtonComponentWithSKUId, APIMessageComponentEmoji } from 'discord-api-types/v10';
export interface EmojiOrLabelButtonData
extends Pick<Exclude<APIButtonComponent, APIButtonComponentWithSKUId>, 'emoji' | 'label'> {}
export class EmojiOrLabelButtonMixin {
protected declare readonly data: EmojiOrLabelButtonData;
/**
* Sets the emoji to display on this button.
*
* @param emoji - The emoji to use
*/
public setEmoji(emoji: APIMessageComponentEmoji) {
this.data.emoji = emoji;
return this;
}
/**
* Clears the emoji on this button.
*/
public clearEmoji() {
this.data.emoji = undefined;
return this;
}
/**
* Sets the label for this button.
*
* @param label - The label to use
*/
public setLabel(label: string) {
this.data.label = label;
return this;
}
/**
* Clears the label on this button.
*/
public clearLabel() {
this.data.label = undefined;
return this;
}
}

View File

@@ -1,5 +1,5 @@
import type { JSONEncodable } from '@discordjs/util';
import type { APISelectMenuComponent } from 'discord-api-types/v10';
import { customIdValidator, disabledValidator, minMaxValidator, placeholderValidator } from '../Assertions.js';
import { ComponentBuilder } from '../Component.js';
/**
@@ -7,16 +7,29 @@ import { ComponentBuilder } from '../Component.js';
*
* @typeParam SelectMenuType - The type of select menu this would be instantiated for.
*/
export abstract class BaseSelectMenuBuilder<
SelectMenuType extends APISelectMenuComponent,
> extends ComponentBuilder<SelectMenuType> {
export abstract class BaseSelectMenuBuilder<Data extends APISelectMenuComponent>
extends ComponentBuilder<Data>
implements JSONEncodable<APISelectMenuComponent>
{
protected abstract readonly data: Partial<
Pick<Data, 'custom_id' | 'disabled' | 'max_values' | 'min_values' | 'placeholder'>
>;
/**
* Sets the placeholder for this select menu.
*
* @param placeholder - The placeholder to use
*/
public setPlaceholder(placeholder: string) {
this.data.placeholder = placeholderValidator.parse(placeholder);
this.data.placeholder = placeholder;
return this;
}
/**
* Clears the placeholder for this select menu.
*/
public clearPlaceholder() {
this.data.placeholder = undefined;
return this;
}
@@ -26,7 +39,7 @@ export abstract class BaseSelectMenuBuilder<
* @param minValues - The minimum values that must be selected
*/
public setMinValues(minValues: number) {
this.data.min_values = minMaxValidator.parse(minValues);
this.data.min_values = minValues;
return this;
}
@@ -36,7 +49,7 @@ export abstract class BaseSelectMenuBuilder<
* @param maxValues - The maximum values that must be selected
*/
public setMaxValues(maxValues: number) {
this.data.max_values = minMaxValidator.parse(maxValues);
this.data.max_values = maxValues;
return this;
}
@@ -46,7 +59,7 @@ export abstract class BaseSelectMenuBuilder<
* @param customId - The custom id to use
*/
public setCustomId(customId: string) {
this.data.custom_id = customIdValidator.parse(customId);
this.data.custom_id = customId;
return this;
}
@@ -56,17 +69,7 @@ export abstract class BaseSelectMenuBuilder<
* @param disabled - Whether this select menu is disabled
*/
public setDisabled(disabled = true) {
this.data.disabled = disabledValidator.parse(disabled);
this.data.disabled = disabled;
return this;
}
/**
* {@inheritDoc ComponentBuilder.toJSON}
*/
public toJSON(): SelectMenuType {
customIdValidator.parse(this.data.custom_id);
return {
...this.data,
} as SelectMenuType;
}
}

View File

@@ -6,13 +6,16 @@ import {
SelectMenuDefaultValueType,
} from 'discord-api-types/v10';
import { type RestOrArray, normalizeArray } from '../../util/normalizeArray.js';
import { channelTypesValidator, customIdValidator, optionsLengthValidator } from '../Assertions.js';
import { isValidationEnabled } from '../../util/validation.js';
import { selectMenuChannelPredicate } from '../Assertions.js';
import { BaseSelectMenuBuilder } from './BaseSelectMenu.js';
/**
* A builder that creates API-compatible JSON data for channel select menus.
*/
export class ChannelSelectMenuBuilder extends BaseSelectMenuBuilder<APIChannelSelectComponent> {
protected override readonly data: Partial<APIChannelSelectComponent>;
/**
* Creates a new select menu from API data.
*
@@ -36,8 +39,9 @@ export class ChannelSelectMenuBuilder extends BaseSelectMenuBuilder<APIChannelSe
* .setMinValues(2);
* ```
*/
public constructor(data?: Partial<APIChannelSelectComponent>) {
super({ ...data, type: ComponentType.ChannelSelect });
public constructor(data: Partial<APIChannelSelectComponent> = {}) {
super();
this.data = { ...structuredClone(data), type: ComponentType.ChannelSelect };
}
/**
@@ -48,7 +52,7 @@ export class ChannelSelectMenuBuilder extends BaseSelectMenuBuilder<APIChannelSe
public addChannelTypes(...types: RestOrArray<ChannelType>) {
const normalizedTypes = normalizeArray(types);
this.data.channel_types ??= [];
this.data.channel_types.push(...channelTypesValidator.parse(normalizedTypes));
this.data.channel_types.push(...normalizedTypes);
return this;
}
@@ -60,7 +64,7 @@ export class ChannelSelectMenuBuilder extends BaseSelectMenuBuilder<APIChannelSe
public setChannelTypes(...types: RestOrArray<ChannelType>) {
const normalizedTypes = normalizeArray(types);
this.data.channel_types ??= [];
this.data.channel_types.splice(0, this.data.channel_types.length, ...channelTypesValidator.parse(normalizedTypes));
this.data.channel_types.splice(0, this.data.channel_types.length, ...normalizedTypes);
return this;
}
@@ -71,7 +75,6 @@ export class ChannelSelectMenuBuilder extends BaseSelectMenuBuilder<APIChannelSe
*/
public addDefaultChannels(...channels: RestOrArray<Snowflake>) {
const normalizedValues = normalizeArray(channels);
optionsLengthValidator.parse((this.data.default_values?.length ?? 0) + normalizedValues.length);
this.data.default_values ??= [];
this.data.default_values.push(
@@ -91,7 +94,6 @@ export class ChannelSelectMenuBuilder extends BaseSelectMenuBuilder<APIChannelSe
*/
public setDefaultChannels(...channels: RestOrArray<Snowflake>) {
const normalizedValues = normalizeArray(channels);
optionsLengthValidator.parse(normalizedValues.length);
this.data.default_values = normalizedValues.map((id) => ({
id,
@@ -102,13 +104,15 @@ export class ChannelSelectMenuBuilder extends BaseSelectMenuBuilder<APIChannelSe
}
/**
* {@inheritDoc BaseSelectMenuBuilder.toJSON}
* {@inheritDoc ComponentBuilder.toJSON}
*/
public override toJSON(): APIChannelSelectComponent {
customIdValidator.parse(this.data.custom_id);
public override toJSON(validationOverride?: boolean): APIChannelSelectComponent {
const clone = structuredClone(this.data);
return {
...this.data,
} as APIChannelSelectComponent;
if (validationOverride ?? isValidationEnabled()) {
selectMenuChannelPredicate.parse(clone);
}
return clone as APIChannelSelectComponent;
}
}

View File

@@ -6,13 +6,16 @@ import {
SelectMenuDefaultValueType,
} from 'discord-api-types/v10';
import { type RestOrArray, normalizeArray } from '../../util/normalizeArray.js';
import { optionsLengthValidator } from '../Assertions.js';
import { isValidationEnabled } from '../../util/validation.js';
import { selectMenuMentionablePredicate } from '../Assertions.js';
import { BaseSelectMenuBuilder } from './BaseSelectMenu.js';
/**
* A builder that creates API-compatible JSON data for mentionable select menus.
*/
export class MentionableSelectMenuBuilder extends BaseSelectMenuBuilder<APIMentionableSelectComponent> {
protected override readonly data: Partial<APIMentionableSelectComponent>;
/**
* Creates a new select menu from API data.
*
@@ -35,8 +38,9 @@ export class MentionableSelectMenuBuilder extends BaseSelectMenuBuilder<APIMenti
* .setMinValues(1);
* ```
*/
public constructor(data?: Partial<APIMentionableSelectComponent>) {
super({ ...data, type: ComponentType.MentionableSelect });
public constructor(data: Partial<APIMentionableSelectComponent> = {}) {
super();
this.data = { ...structuredClone(data), type: ComponentType.MentionableSelect };
}
/**
@@ -46,7 +50,6 @@ export class MentionableSelectMenuBuilder extends BaseSelectMenuBuilder<APIMenti
*/
public addDefaultRoles(...roles: RestOrArray<Snowflake>) {
const normalizedValues = normalizeArray(roles);
optionsLengthValidator.parse((this.data.default_values?.length ?? 0) + normalizedValues.length);
this.data.default_values ??= [];
this.data.default_values.push(
@@ -66,7 +69,6 @@ export class MentionableSelectMenuBuilder extends BaseSelectMenuBuilder<APIMenti
*/
public addDefaultUsers(...users: RestOrArray<Snowflake>) {
const normalizedValues = normalizeArray(users);
optionsLengthValidator.parse((this.data.default_values?.length ?? 0) + normalizedValues.length);
this.data.default_values ??= [];
this.data.default_values.push(
@@ -91,7 +93,6 @@ export class MentionableSelectMenuBuilder extends BaseSelectMenuBuilder<APIMenti
>
) {
const normalizedValues = normalizeArray(values);
optionsLengthValidator.parse((this.data.default_values?.length ?? 0) + normalizedValues.length);
this.data.default_values ??= [];
this.data.default_values.push(...normalizedValues);
return this;
@@ -109,8 +110,20 @@ export class MentionableSelectMenuBuilder extends BaseSelectMenuBuilder<APIMenti
>
) {
const normalizedValues = normalizeArray(values);
optionsLengthValidator.parse(normalizedValues.length);
this.data.default_values = normalizedValues;
return this;
}
/**
* {@inheritDoc ComponentBuilder.toJSON}
*/
public override toJSON(validationOverride?: boolean): APIMentionableSelectComponent {
const clone = structuredClone(this.data);
if (validationOverride ?? isValidationEnabled()) {
selectMenuMentionablePredicate.parse(clone);
}
return clone as APIMentionableSelectComponent;
}
}

View File

@@ -5,13 +5,16 @@ import {
SelectMenuDefaultValueType,
} from 'discord-api-types/v10';
import { type RestOrArray, normalizeArray } from '../../util/normalizeArray.js';
import { optionsLengthValidator } from '../Assertions.js';
import { isValidationEnabled } from '../../util/validation.js';
import { selectMenuRolePredicate } from '../Assertions.js';
import { BaseSelectMenuBuilder } from './BaseSelectMenu.js';
/**
* A builder that creates API-compatible JSON data for role select menus.
*/
export class RoleSelectMenuBuilder extends BaseSelectMenuBuilder<APIRoleSelectComponent> {
protected override readonly data: Partial<APIRoleSelectComponent>;
/**
* Creates a new select menu from API data.
*
@@ -34,8 +37,9 @@ export class RoleSelectMenuBuilder extends BaseSelectMenuBuilder<APIRoleSelectCo
* .setMinValues(1);
* ```
*/
public constructor(data?: Partial<APIRoleSelectComponent>) {
super({ ...data, type: ComponentType.RoleSelect });
public constructor(data: Partial<APIRoleSelectComponent> = {}) {
super();
this.data = { ...structuredClone(data), type: ComponentType.RoleSelect };
}
/**
@@ -45,7 +49,6 @@ export class RoleSelectMenuBuilder extends BaseSelectMenuBuilder<APIRoleSelectCo
*/
public addDefaultRoles(...roles: RestOrArray<Snowflake>) {
const normalizedValues = normalizeArray(roles);
optionsLengthValidator.parse((this.data.default_values?.length ?? 0) + normalizedValues.length);
this.data.default_values ??= [];
this.data.default_values.push(
@@ -65,7 +68,6 @@ export class RoleSelectMenuBuilder extends BaseSelectMenuBuilder<APIRoleSelectCo
*/
public setDefaultRoles(...roles: RestOrArray<Snowflake>) {
const normalizedValues = normalizeArray(roles);
optionsLengthValidator.parse(normalizedValues.length);
this.data.default_values = normalizedValues.map((id) => ({
id,
@@ -74,4 +76,17 @@ export class RoleSelectMenuBuilder extends BaseSelectMenuBuilder<APIRoleSelectCo
return this;
}
/**
* {@inheritDoc ComponentBuilder.toJSON}
*/
public override toJSON(validationOverride?: boolean): APIRoleSelectComponent {
const clone = structuredClone(this.data);
if (validationOverride ?? isValidationEnabled()) {
selectMenuRolePredicate.parse(clone);
}
return clone as APIRoleSelectComponent;
}
}

View File

@@ -1,18 +1,30 @@
/* eslint-disable jsdoc/check-param-names */
import { ComponentType } from 'discord-api-types/v10';
import type { APIStringSelectComponent, APISelectMenuOption } from 'discord-api-types/v10';
import { normalizeArray, type RestOrArray } from '../../util/normalizeArray.js';
import { jsonOptionValidator, optionsLengthValidator, validateRequiredSelectMenuParameters } from '../Assertions.js';
import { resolveBuilder } from '../../util/resolveBuilder.js';
import { isValidationEnabled } from '../../util/validation.js';
import { selectMenuStringPredicate } from '../Assertions.js';
import { BaseSelectMenuBuilder } from './BaseSelectMenu.js';
import { StringSelectMenuOptionBuilder } from './StringSelectMenuOption.js';
export interface StringSelectMenuData extends Partial<Omit<APIStringSelectComponent, 'options'>> {
options: StringSelectMenuOptionBuilder[];
}
/**
* A builder that creates API-compatible JSON data for string select menus.
*/
export class StringSelectMenuBuilder extends BaseSelectMenuBuilder<APIStringSelectComponent> {
protected override readonly data: StringSelectMenuData;
/**
* The options within this select menu.
* The options for this select menu.
*/
public readonly options: StringSelectMenuOptionBuilder[];
public get options(): readonly StringSelectMenuOptionBuilder[] {
return this.data.options;
}
/**
* Creates a new select menu from API data.
@@ -45,10 +57,13 @@ export class StringSelectMenuBuilder extends BaseSelectMenuBuilder<APIStringSele
* });
* ```
*/
public constructor(data?: Partial<APIStringSelectComponent>) {
const { options, ...initData } = data ?? {};
super({ ...initData, type: ComponentType.StringSelect });
this.options = options?.map((option: APISelectMenuOption) => new StringSelectMenuOptionBuilder(option)) ?? [];
public constructor({ options = [], ...data }: Partial<APIStringSelectComponent> = {}) {
super();
this.data = {
...structuredClone(data),
options: options.map((option) => new StringSelectMenuOptionBuilder(option)),
type: ComponentType.StringSelect,
};
}
/**
@@ -56,16 +71,18 @@ export class StringSelectMenuBuilder extends BaseSelectMenuBuilder<APIStringSele
*
* @param options - The options to add
*/
public addOptions(...options: RestOrArray<APISelectMenuOption | StringSelectMenuOptionBuilder>) {
public addOptions(
...options: RestOrArray<
| APISelectMenuOption
| StringSelectMenuOptionBuilder
| ((builder: StringSelectMenuOptionBuilder) => StringSelectMenuOptionBuilder)
>
) {
const normalizedOptions = normalizeArray(options);
optionsLengthValidator.parse(this.options.length + normalizedOptions.length);
this.options.push(
...normalizedOptions.map((normalizedOption) =>
normalizedOption instanceof StringSelectMenuOptionBuilder
? normalizedOption
: new StringSelectMenuOptionBuilder(jsonOptionValidator.parse(normalizedOption)),
),
);
const resolved = normalizedOptions.map((option) => resolveBuilder(option, StringSelectMenuOptionBuilder));
this.data.options.push(...resolved);
return this;
}
@@ -74,8 +91,14 @@ export class StringSelectMenuBuilder extends BaseSelectMenuBuilder<APIStringSele
*
* @param options - The options to set
*/
public setOptions(...options: RestOrArray<APISelectMenuOption | StringSelectMenuOptionBuilder>) {
return this.spliceOptions(0, this.options.length, ...options);
public setOptions(
...options: RestOrArray<
| APISelectMenuOption
| StringSelectMenuOptionBuilder
| ((builder: StringSelectMenuOptionBuilder) => StringSelectMenuOptionBuilder)
>
) {
return this.spliceOptions(0, this.options.length, ...normalizeArray(options));
}
/**
@@ -108,36 +131,35 @@ export class StringSelectMenuBuilder extends BaseSelectMenuBuilder<APIStringSele
public spliceOptions(
index: number,
deleteCount: number,
...options: RestOrArray<APISelectMenuOption | StringSelectMenuOptionBuilder>
...options: (
| APISelectMenuOption
| StringSelectMenuOptionBuilder
| ((builder: StringSelectMenuOptionBuilder) => StringSelectMenuOptionBuilder)
)[]
) {
const normalizedOptions = normalizeArray(options);
const resolved = options.map((option) => resolveBuilder(option, StringSelectMenuOptionBuilder));
const clone = [...this.options];
this.data.options ??= [];
this.data.options.splice(index, deleteCount, ...resolved);
clone.splice(
index,
deleteCount,
...normalizedOptions.map((normalizedOption) =>
normalizedOption instanceof StringSelectMenuOptionBuilder
? normalizedOption
: new StringSelectMenuOptionBuilder(jsonOptionValidator.parse(normalizedOption)),
),
);
optionsLengthValidator.parse(clone.length);
this.options.splice(0, this.options.length, ...clone);
return this;
}
/**
* {@inheritDoc BaseSelectMenuBuilder.toJSON}
* {@inheritDoc ComponentBuilder.toJSON}
*/
public override toJSON(): APIStringSelectComponent {
validateRequiredSelectMenuParameters(this.options, this.data.custom_id);
public override toJSON(validationOverride?: boolean): APIStringSelectComponent {
const { options, ...rest } = this.data;
const data = {
...(structuredClone(rest) as APIStringSelectComponent),
// selectMenuStringPredicate covers the validation of options
options: options.map((option) => option.toJSON(false)),
};
return {
...this.data,
options: this.options.map((option) => option.toJSON()),
} as APIStringSelectComponent;
if (validationOverride ?? isValidationEnabled()) {
selectMenuStringPredicate.parse(data);
}
return data as APIStringSelectComponent;
}
}

View File

@@ -1,16 +1,14 @@
import type { JSONEncodable } from '@discordjs/util';
import type { APIMessageComponentEmoji, APISelectMenuOption } from 'discord-api-types/v10';
import {
defaultValidator,
emojiValidator,
labelValueDescriptionValidator,
validateRequiredSelectMenuOptionParameters,
} from '../Assertions.js';
import { isValidationEnabled } from '../../util/validation.js';
import { selectMenuStringOptionPredicate } from '../Assertions.js';
/**
* A builder that creates API-compatible JSON data for string select menu options.
*/
export class StringSelectMenuOptionBuilder implements JSONEncodable<APISelectMenuOption> {
private readonly data: Partial<APISelectMenuOption>;
/**
* Creates a new string select menu option from API data.
*
@@ -33,7 +31,9 @@ export class StringSelectMenuOptionBuilder implements JSONEncodable<APISelectMen
* .setLabel('woah');
* ```
*/
public constructor(public data: Partial<APISelectMenuOption> = {}) {}
public constructor(data: Partial<APISelectMenuOption> = {}) {
this.data = structuredClone(data);
}
/**
* Sets the label for this option.
@@ -41,7 +41,7 @@ export class StringSelectMenuOptionBuilder implements JSONEncodable<APISelectMen
* @param label - The label to use
*/
public setLabel(label: string) {
this.data.label = labelValueDescriptionValidator.parse(label);
this.data.label = label;
return this;
}
@@ -51,7 +51,7 @@ export class StringSelectMenuOptionBuilder implements JSONEncodable<APISelectMen
* @param value - The value to use
*/
public setValue(value: string) {
this.data.value = labelValueDescriptionValidator.parse(value);
this.data.value = value;
return this;
}
@@ -61,7 +61,15 @@ export class StringSelectMenuOptionBuilder implements JSONEncodable<APISelectMen
* @param description - The description to use
*/
public setDescription(description: string) {
this.data.description = labelValueDescriptionValidator.parse(description);
this.data.description = description;
return this;
}
/**
* Clears the description for this option.
*/
public clearDescription() {
this.data.description = undefined;
return this;
}
@@ -71,7 +79,7 @@ export class StringSelectMenuOptionBuilder implements JSONEncodable<APISelectMen
* @param isDefault - Whether this option is selected by default
*/
public setDefault(isDefault = true) {
this.data.default = defaultValidator.parse(isDefault);
this.data.default = isDefault;
return this;
}
@@ -81,18 +89,28 @@ export class StringSelectMenuOptionBuilder implements JSONEncodable<APISelectMen
* @param emoji - The emoji to use
*/
public setEmoji(emoji: APIMessageComponentEmoji) {
this.data.emoji = emojiValidator.parse(emoji);
this.data.emoji = emoji;
return this;
}
/**
* {@inheritDoc BaseSelectMenuBuilder.toJSON}
* Clears the emoji for this option.
*/
public toJSON(): APISelectMenuOption {
validateRequiredSelectMenuOptionParameters(this.data.label, this.data.value);
public clearEmoji() {
this.data.emoji = undefined;
return this;
}
return {
...this.data,
} as APISelectMenuOption;
/**
* {@inheritDoc ComponentBuilder.toJSON}
*/
public toJSON(validationOverride?: boolean): APISelectMenuOption {
const clone = structuredClone(this.data);
if (validationOverride ?? isValidationEnabled()) {
selectMenuStringOptionPredicate.parse(clone);
}
return clone as APISelectMenuOption;
}
}

View File

@@ -5,13 +5,16 @@ import {
SelectMenuDefaultValueType,
} from 'discord-api-types/v10';
import { type RestOrArray, normalizeArray } from '../../util/normalizeArray.js';
import { optionsLengthValidator } from '../Assertions.js';
import { isValidationEnabled } from '../../util/validation.js';
import { selectMenuUserPredicate } from '../Assertions.js';
import { BaseSelectMenuBuilder } from './BaseSelectMenu.js';
/**
* A builder that creates API-compatible JSON data for user select menus.
*/
export class UserSelectMenuBuilder extends BaseSelectMenuBuilder<APIUserSelectComponent> {
protected override readonly data: Partial<APIUserSelectComponent>;
/**
* Creates a new select menu from API data.
*
@@ -34,8 +37,9 @@ export class UserSelectMenuBuilder extends BaseSelectMenuBuilder<APIUserSelectCo
* .setMinValues(1);
* ```
*/
public constructor(data?: Partial<APIUserSelectComponent>) {
super({ ...data, type: ComponentType.UserSelect });
public constructor(data: Partial<APIUserSelectComponent> = {}) {
super();
this.data = { ...structuredClone(data), type: ComponentType.UserSelect };
}
/**
@@ -45,9 +49,8 @@ export class UserSelectMenuBuilder extends BaseSelectMenuBuilder<APIUserSelectCo
*/
public addDefaultUsers(...users: RestOrArray<Snowflake>) {
const normalizedValues = normalizeArray(users);
optionsLengthValidator.parse((this.data.default_values?.length ?? 0) + normalizedValues.length);
this.data.default_values ??= [];
this.data.default_values ??= [];
this.data.default_values.push(
...normalizedValues.map((id) => ({
id,
@@ -65,7 +68,6 @@ export class UserSelectMenuBuilder extends BaseSelectMenuBuilder<APIUserSelectCo
*/
public setDefaultUsers(...users: RestOrArray<Snowflake>) {
const normalizedValues = normalizeArray(users);
optionsLengthValidator.parse(normalizedValues.length);
this.data.default_values = normalizedValues.map((id) => ({
id,
@@ -74,4 +76,17 @@ export class UserSelectMenuBuilder extends BaseSelectMenuBuilder<APIUserSelectCo
return this;
}
/**
* {@inheritDoc ComponentBuilder.toJSON}
*/
public override toJSON(validationOverride?: boolean): APIUserSelectComponent {
const clone = structuredClone(this.data);
if (validationOverride ?? isValidationEnabled()) {
selectMenuUserPredicate.parse(clone);
}
return clone as APIUserSelectComponent;
}
}

View File

@@ -1,32 +1,15 @@
import { s } from '@sapphire/shapeshift';
import { TextInputStyle } from 'discord-api-types/v10';
import { isValidationEnabled } from '../../util/validation.js';
import { customIdValidator } from '../Assertions.js';
import { ComponentType, TextInputStyle } from 'discord-api-types/v10';
import { z } from 'zod';
import { customIdPredicate } from '../../Assertions.js';
export const textInputStyleValidator = s.nativeEnum(TextInputStyle);
export const minLengthValidator = s
.number()
.int()
.greaterThanOrEqual(0)
.lessThanOrEqual(4_000)
.setValidationEnabled(isValidationEnabled);
export const maxLengthValidator = s
.number()
.int()
.greaterThanOrEqual(1)
.lessThanOrEqual(4_000)
.setValidationEnabled(isValidationEnabled);
export const requiredValidator = s.boolean();
export const valueValidator = s.string().lengthLessThanOrEqual(4_000).setValidationEnabled(isValidationEnabled);
export const placeholderValidator = s.string().lengthLessThanOrEqual(100).setValidationEnabled(isValidationEnabled);
export const labelValidator = s
.string()
.lengthGreaterThanOrEqual(1)
.lengthLessThanOrEqual(45)
.setValidationEnabled(isValidationEnabled);
export function validateRequiredParameters(customId?: string, style?: TextInputStyle, label?: string) {
customIdValidator.parse(customId);
textInputStyleValidator.parse(style);
labelValidator.parse(label);
}
export const textInputPredicate = z.object({
type: z.literal(ComponentType.TextInput),
custom_id: customIdPredicate,
label: z.string().min(1).max(45),
style: z.nativeEnum(TextInputStyle),
min_length: z.number().min(0).max(4_000).optional(),
max_length: z.number().min(1).max(4_000).optional(),
placeholder: z.string().max(100).optional(),
value: z.string().max(4_000).optional(),
required: z.boolean().optional(),
});

View File

@@ -1,26 +1,14 @@
import { isJSONEncodable, type Equatable, type JSONEncodable } from '@discordjs/util';
import { ComponentType, type TextInputStyle, type APITextInputComponent } from 'discord-api-types/v10';
import isEqual from 'fast-deep-equal';
import { customIdValidator } from '../Assertions.js';
import { isValidationEnabled } from '../../util/validation.js';
import { ComponentBuilder } from '../Component.js';
import {
maxLengthValidator,
minLengthValidator,
placeholderValidator,
requiredValidator,
valueValidator,
validateRequiredParameters,
labelValidator,
textInputStyleValidator,
} from './Assertions.js';
import { textInputPredicate } from './Assertions.js';
/**
* A builder that creates API-compatible JSON data for text inputs.
*/
export class TextInputBuilder
extends ComponentBuilder<APITextInputComponent>
implements Equatable<APITextInputComponent | JSONEncodable<APITextInputComponent>>
{
export class TextInputBuilder extends ComponentBuilder<APITextInputComponent> {
private readonly data: Partial<APITextInputComponent>;
/**
* Creates a new text input from API data.
*
@@ -44,8 +32,9 @@ export class TextInputBuilder
* .setStyle(TextInputStyle.Paragraph);
* ```
*/
public constructor(data?: APITextInputComponent & { type?: ComponentType.TextInput }) {
super({ type: ComponentType.TextInput, ...data });
public constructor(data: Partial<APITextInputComponent> = {}) {
super();
this.data = { ...structuredClone(data), type: ComponentType.TextInput };
}
/**
@@ -54,7 +43,7 @@ export class TextInputBuilder
* @param customId - The custom id to use
*/
public setCustomId(customId: string) {
this.data.custom_id = customIdValidator.parse(customId);
this.data.custom_id = customId;
return this;
}
@@ -64,7 +53,7 @@ export class TextInputBuilder
* @param label - The label to use
*/
public setLabel(label: string) {
this.data.label = labelValidator.parse(label);
this.data.label = label;
return this;
}
@@ -74,7 +63,7 @@ export class TextInputBuilder
* @param style - The style to use
*/
public setStyle(style: TextInputStyle) {
this.data.style = textInputStyleValidator.parse(style);
this.data.style = style;
return this;
}
@@ -84,7 +73,15 @@ export class TextInputBuilder
* @param minLength - The minimum length of text for this text input
*/
public setMinLength(minLength: number) {
this.data.min_length = minLengthValidator.parse(minLength);
this.data.min_length = minLength;
return this;
}
/**
* Clears the minimum length of text for this text input.
*/
public clearMinLength() {
this.data.min_length = undefined;
return this;
}
@@ -94,7 +91,15 @@ export class TextInputBuilder
* @param maxLength - The maximum length of text for this text input
*/
public setMaxLength(maxLength: number) {
this.data.max_length = maxLengthValidator.parse(maxLength);
this.data.max_length = maxLength;
return this;
}
/**
* Clears the maximum length of text for this text input.
*/
public clearMaxLength() {
this.data.max_length = undefined;
return this;
}
@@ -104,7 +109,15 @@ export class TextInputBuilder
* @param placeholder - The placeholder to use
*/
public setPlaceholder(placeholder: string) {
this.data.placeholder = placeholderValidator.parse(placeholder);
this.data.placeholder = placeholder;
return this;
}
/**
* Clears the placeholder for this text input.
*/
public clearPlaceholder() {
this.data.placeholder = undefined;
return this;
}
@@ -114,7 +127,15 @@ export class TextInputBuilder
* @param value - The value to use
*/
public setValue(value: string) {
this.data.value = valueValidator.parse(value);
this.data.value = value;
return this;
}
/**
* Clears the value for this text input.
*/
public clearValue() {
this.data.value = undefined;
return this;
}
@@ -124,29 +145,20 @@ export class TextInputBuilder
* @param required - Whether this text input is required
*/
public setRequired(required = true) {
this.data.required = requiredValidator.parse(required);
this.data.required = required;
return this;
}
/**
* {@inheritDoc ComponentBuilder.toJSON}
*/
public toJSON(): APITextInputComponent {
validateRequiredParameters(this.data.custom_id, this.data.style, this.data.label);
public toJSON(validationOverride?: boolean): APITextInputComponent {
const clone = structuredClone(this.data);
return {
...this.data,
} as APITextInputComponent;
}
/**
* Whether this is equal to another structure.
*/
public equals(other: APITextInputComponent | JSONEncodable<APITextInputComponent>): boolean {
if (isJSONEncodable(other)) {
return isEqual(other.toJSON(), this.data);
if (validationOverride ?? isValidationEnabled()) {
textInputPredicate.parse(clone);
}
return isEqual(other, this.data);
return clone as APITextInputComponent;
}
}