From e784dd6b599e865ba6006989a1948f067da1c27c Mon Sep 17 00:00:00 2001 From: didinele Date: Mon, 23 Sep 2024 15:12:19 +0300 Subject: [PATCH] refactor: split button --- .../__tests__/components/actionRow.test.ts | 18 +-- .../__tests__/components/button.test.ts | 105 +++----------- .../__tests__/components/components.test.ts | 6 +- packages/builders/src/components/ActionRow.ts | 90 ++++++++---- .../builders/src/components/Components.ts | 36 ++++- .../builders/src/components/button/Button.ts | 134 +----------------- .../src/components/button/CustomIdButton.ts | 43 ++++++ .../src/components/button/SKUIdButton.ts | 26 ++++ .../src/components/button/URLButton.ts | 34 +++++ .../button/mixins/URLOrCustomIdButtonMixin.ts | 44 ++++++ packages/builders/src/index.ts | 4 + 11 files changed, 274 insertions(+), 266 deletions(-) create mode 100644 packages/builders/src/components/button/CustomIdButton.ts create mode 100644 packages/builders/src/components/button/SKUIdButton.ts create mode 100644 packages/builders/src/components/button/URLButton.ts create mode 100644 packages/builders/src/components/button/mixins/URLOrCustomIdButtonMixin.ts diff --git a/packages/builders/__tests__/components/actionRow.test.ts b/packages/builders/__tests__/components/actionRow.test.ts index daf605664264..5a00e2301682 100644 --- a/packages/builders/__tests__/components/actionRow.test.ts +++ b/packages/builders/__tests__/components/actionRow.test.ts @@ -9,6 +9,7 @@ import { ActionRowBuilder, ButtonBuilder, createComponentBuilder, + CustomIdButtonBuilder, StringSelectMenuBuilder, StringSelectMenuOptionBuilder, } from '../../src/index.js'; @@ -49,13 +50,6 @@ const rowWithSelectMenuData: APIActionRowComponent describe('Action Row Components', () => { describe('Assertion Tests', () => { - test('GIVEN valid components THEN do not throw', () => { - expect(() => - new ActionRowBuilder().addButtonComponents(new ButtonBuilder(), new ButtonBuilder()), - ).not.toThrowError(); - expect(() => new ActionRowBuilder().addButtonComponents([new ButtonBuilder()])).not.toThrowError(); - }); - test('GIVEN valid JSON input THEN valid JSON output is given', () => { const actionRowData: APIActionRowComponent = { type: ComponentType.ActionRow, @@ -120,7 +114,7 @@ describe('Action Row Components', () => { }); test('GIVEN valid builder options THEN valid JSON output is given 2', () => { - const button = new ButtonBuilder().setLabel('test').setStyle(ButtonStyle.Primary).setCustomId('123'); + const button = new CustomIdButtonBuilder().setLabel('test').setStyle(ButtonStyle.Primary).setCustomId('123'); const selectMenu = new StringSelectMenuBuilder() .setCustomId('1234') .setMaxValues(2) @@ -134,9 +128,9 @@ describe('Action Row Components', () => { new StringSelectMenuOptionBuilder().setLabel('two').setValue('two'), ]); - expect(new ActionRowBuilder().addButtonComponents(button).toJSON()).toEqual(rowWithButtonData); + expect(new ActionRowBuilder().addCustomIdButtonComponents(button).toJSON()).toEqual(rowWithButtonData); expect(new ActionRowBuilder().addStringSelectMenuComponent(selectMenu).toJSON()).toEqual(rowWithSelectMenuData); - expect(new ActionRowBuilder().addButtonComponents([button]).toJSON()).toEqual(rowWithButtonData); + expect(new ActionRowBuilder().addCustomIdButtonComponents([button]).toJSON()).toEqual(rowWithButtonData); }); test('GIVEN 2 select menus THEN it throws', () => { @@ -156,7 +150,7 @@ describe('Action Row Components', () => { }); test('GIVEN a button and a select menu THEN it throws', () => { - const button = new ButtonBuilder().setLabel('test').setStyle(ButtonStyle.Primary).setCustomId('123'); + const button = new CustomIdButtonBuilder().setLabel('test').setStyle(ButtonStyle.Primary).setCustomId('123'); const selectMenu = new StringSelectMenuBuilder() .setCustomId('1234') .setOptions( @@ -165,7 +159,7 @@ describe('Action Row Components', () => { ); expect(() => - new ActionRowBuilder().addStringSelectMenuComponent(selectMenu).addButtonComponents(button).toJSON(), + new ActionRowBuilder().addStringSelectMenuComponent(selectMenu).addCustomIdButtonComponents(button).toJSON(), ).toThrowError(); }); }); diff --git a/packages/builders/__tests__/components/button.test.ts b/packages/builders/__tests__/components/button.test.ts index 0411e04681c5..994446ad3bcc 100644 --- a/packages/builders/__tests__/components/button.test.ts +++ b/packages/builders/__tests__/components/button.test.ts @@ -6,8 +6,7 @@ import { } from 'discord-api-types/v10'; import { describe, test, expect } from 'vitest'; import { ButtonBuilder } from '../../src/components/button/Button.js'; - -const buttonComponent = () => new ButtonBuilder(); +import { CustomIdButtonBuilder, SKUIdButtonBuilder, URLButtonBuilder } from '../../src/index.js'; const longStr = 'looooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong'; @@ -16,11 +15,11 @@ describe('Button Components', () => { describe('Assertion Tests', () => { test('GIVEN valid fields THEN builder does not throw', () => { expect(() => - buttonComponent().setCustomId('custom').setStyle(ButtonStyle.Primary).setLabel('test'), + new CustomIdButtonBuilder().setCustomId('custom').setStyle(ButtonStyle.Primary).setLabel('test'), ).not.toThrowError(); expect(() => { - const button = buttonComponent() + const button = new CustomIdButtonBuilder() .setCustomId('custom') .setLabel('test') .setStyle(ButtonStyle.Primary) @@ -31,111 +30,41 @@ describe('Button Components', () => { }).not.toThrowError(); expect(() => { - const button = buttonComponent().setSKUId('123456789012345678').setStyle(ButtonStyle.Premium); + const button = new SKUIdButtonBuilder().setSKUId('123456789012345678'); button.toJSON(); }).not.toThrowError(); - expect(() => buttonComponent().setURL('https://google.com')).not.toThrowError(); + expect(() => new URLButtonBuilder().setURL('https://google.com')).not.toThrowError(); }); test('GIVEN invalid fields THEN build does throw', () => { expect(() => { - buttonComponent().setCustomId(longStr).toJSON(); - }).toThrowError(); - - expect(() => { - const button = buttonComponent() - .setCustomId('custom') - .setStyle(ButtonStyle.Primary) - .setDisabled(true) - .setLabel('test') - .setURL('https://google.com') - .setEmoji({ name: 'test' }); - - button.toJSON(); + new CustomIdButtonBuilder().setCustomId(longStr).toJSON(); }).toThrowError(); expect(() => { // @ts-expect-error: Invalid emoji - const button = buttonComponent().setEmoji('test'); + const button = new CustomIdButtonBuilder().setEmoji('test'); button.toJSON(); }).toThrowError(); expect(() => { - const button = buttonComponent().setStyle(ButtonStyle.Primary); + const button = new CustomIdButtonBuilder().setStyle(ButtonStyle.Primary); button.toJSON(); }).toThrowError(); expect(() => { - const button = buttonComponent().setStyle(ButtonStyle.Primary).setCustomId('test'); - button.toJSON(); - }).toThrowError(); - - expect(() => { - const button = buttonComponent().setStyle(ButtonStyle.Link); - button.toJSON(); - }).toThrowError(); - - expect(() => { - const button = buttonComponent().setStyle(ButtonStyle.Primary).setLabel('test').setURL('https://google.com'); - button.toJSON(); - }).toThrowError(); - - expect(() => { - const button = buttonComponent().setStyle(ButtonStyle.Link).setLabel('test'); - button.toJSON(); - }).toThrowError(); - - expect(() => { - const button = buttonComponent().setStyle(ButtonStyle.Primary).setSKUId('123456789012345678'); - button.toJSON(); - }).toThrowError(); - - expect(() => { - const button = buttonComponent() - .setStyle(ButtonStyle.Secondary) - .setLabel('button') - .setSKUId('123456789012345678'); - - button.toJSON(); - }).toThrowError(); - - expect(() => { - const button = buttonComponent() - .setStyle(ButtonStyle.Success) - .setEmoji({ name: '😇' }) - .setSKUId('123456789012345678'); - - button.toJSON(); - }).toThrowError(); - - expect(() => { - const button = buttonComponent() - .setStyle(ButtonStyle.Danger) - .setCustomId('test') - .setSKUId('123456789012345678'); - - button.toJSON(); - }).toThrowError(); - - expect(() => { - const button = buttonComponent() - .setStyle(ButtonStyle.Link) - .setURL('https://google.com') - .setSKUId('123456789012345678'); - + const button = new CustomIdButtonBuilder().setStyle(ButtonStyle.Primary).setCustomId('test'); button.toJSON(); }).toThrowError(); // @ts-expect-error: Invalid style - expect(() => buttonComponent().setCustomId('hi').setStyle(24).toJSON()).toThrowError(); - expect(() => buttonComponent().setCustomId('hi').setLabel(longStr).toJSON()).toThrowError(); + expect(() => new CustomIdButtonBuilder().setCustomId('hi').setStyle(24).toJSON()).toThrowError(); + expect(() => new CustomIdButtonBuilder().setCustomId('hi').setLabel(longStr).toJSON()).toThrowError(); // @ts-expect-error: Invalid parameter for disabled - expect(() => buttonComponent().setCustomId('hi').setDisabled(0).toJSON()).toThrowError(); + expect(() => new CustomIdButtonBuilder().setCustomId('hi').setDisabled(0).toJSON()).toThrowError(); // @ts-expect-error: Invalid emoji - expect(() => buttonComponent().setCustomId('hi').setEmoji('foo').toJSON()).toThrowError(); - - expect(() => buttonComponent().setCustomId('hi').setURL('foobar').toJSON()).toThrowError(); + expect(() => new CustomIdButtonBuilder().setCustomId('hi').setEmoji('foo').toJSON()).toThrowError(); }); test('GiVEN valid input THEN valid JSON outputs are given', () => { @@ -147,10 +76,10 @@ describe('Button Components', () => { disabled: true, }; - expect(new ButtonBuilder(interactionData).toJSON()).toEqual(interactionData); + expect(new CustomIdButtonBuilder(interactionData).toJSON()).toEqual(interactionData); expect( - buttonComponent() + new CustomIdButtonBuilder() .setCustomId(interactionData.custom_id) .setLabel(interactionData.label!) .setStyle(interactionData.style) @@ -166,9 +95,7 @@ describe('Button Components', () => { url: 'https://google.com', }; - expect(new ButtonBuilder(linkData).toJSON()).toEqual(linkData); - - expect(buttonComponent().setLabel(linkData.label!).setDisabled(true).setURL(linkData.url)); + expect(new URLButtonBuilder(linkData).toJSON()).toEqual(linkData); }); }); }); diff --git a/packages/builders/__tests__/components/components.test.ts b/packages/builders/__tests__/components/components.test.ts index fa0bd4607f65..0612d5a20ed2 100644 --- a/packages/builders/__tests__/components/components.test.ts +++ b/packages/builders/__tests__/components/components.test.ts @@ -11,14 +11,14 @@ import { import { describe, test, expect } from 'vitest'; import { ActionRowBuilder, - ButtonBuilder, createComponentBuilder, + CustomIdButtonBuilder, StringSelectMenuBuilder, TextInputBuilder, } from '../../src/index.js'; describe('createComponentBuilder', () => { - test.each([ButtonBuilder, StringSelectMenuBuilder, TextInputBuilder])( + test.each([StringSelectMenuBuilder, TextInputBuilder])( 'passing an instance of %j should return itself', (Builder) => { const builder = new Builder(); @@ -42,7 +42,7 @@ describe('createComponentBuilder', () => { type: ComponentType.Button, }; - expect(createComponentBuilder(button)).toBeInstanceOf(ButtonBuilder); + expect(createComponentBuilder(button)).toBeInstanceOf(CustomIdButtonBuilder); }); test('GIVEN a select menu component THEN returns a StringSelectMenuBuilder', () => { diff --git a/packages/builders/src/components/ActionRow.ts b/packages/builders/src/components/ActionRow.ts index c437b3cfd71f..d4a0b641baba 100644 --- a/packages/builders/src/components/ActionRow.ts +++ b/packages/builders/src/components/ActionRow.ts @@ -4,12 +4,14 @@ import type { APITextInputComponent, APIActionRowComponent, APIActionRowComponentTypes, - APIButtonComponent, 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'; @@ -19,7 +21,9 @@ import { actionRowPredicate } from './Assertions.js'; import { ComponentBuilder } from './Component.js'; import type { AnyActionRowComponentBuilder } from './Components.js'; import { createComponentBuilder } from './Components.js'; -import { ButtonBuilder } from './button/Button.js'; +import { CustomIdButtonBuilder } from './button/CustomIdButton.js'; +import { SKUIdButtonBuilder } from './button/SKUIdButton.js'; +import { URLButtonBuilder } from './button/URLButton.js'; import { ChannelSelectMenuBuilder } from './selectMenu/ChannelSelectMenu.js'; import { MentionableSelectMenuBuilder } from './selectMenu/MentionableSelectMenu.js'; import { RoleSelectMenuBuilder } from './selectMenu/RoleSelectMenu.js'; @@ -91,18 +95,55 @@ export class ActionRowBuilder extends ComponentBuilder ButtonBuilder)> + public addCustomIdButtonComponents( + ...input: RestOrArray< + | APIButtonComponentWithCustomId + | CustomIdButtonBuilder + | ((builder: CustomIdButtonBuilder) => CustomIdButtonBuilder) + > ): this { const normalized = normalizeArray(input); - for (const button of normalized) { - this.sharedAddComponent(button, ButtonBuilder); - } + const resolved = normalized.map((component) => resolveBuilder(component, CustomIdButtonBuilder)); + + this.data.components.push(...resolved); + return this; + } + + /** + * Adds SKU id button components to this action row. + * + * @param input - The buttons to add + */ + public addSKUIDButtonComponents( + ...input: RestOrArray< + APIButtonComponentWithSKUId | SKUIdButtonBuilder | ((builder: SKUIdButtonBuilder) => SKUIdButtonBuilder) + > + ): this { + const normalized = normalizeArray(input); + const resolved = normalized.map((component) => resolveBuilder(component, SKUIdButtonBuilder)); + this.data.components.push(...resolved); + return this; + } + + /** + * Adds URL button components to this action row. + * + * @param input - The buttons to add + */ + public addURLButtonComponents( + ...input: RestOrArray< + APIButtonComponentWithURL | URLButtonBuilder | ((builder: URLButtonBuilder) => URLButtonBuilder) + > + ): this { + const normalized = normalizeArray(input); + const resolved = normalized.map((component) => resolveBuilder(component, URLButtonBuilder)); + + this.data.components.push(...resolved); return this; } @@ -117,7 +158,8 @@ export class ActionRowBuilder extends ComponentBuilder ChannelSelectMenuBuilder), ): this { - return this.sharedAddComponent(input, ChannelSelectMenuBuilder); + this.data.components.push(resolveBuilder(input, ChannelSelectMenuBuilder)); + return this; } /** @@ -131,7 +173,8 @@ export class ActionRowBuilder extends ComponentBuilder MentionableSelectMenuBuilder), ): this { - return this.sharedAddComponent(input, MentionableSelectMenuBuilder); + this.data.components.push(resolveBuilder(input, MentionableSelectMenuBuilder)); + return this; } /** @@ -142,7 +185,8 @@ export class ActionRowBuilder extends ComponentBuilder RoleSelectMenuBuilder), ): this { - return this.sharedAddComponent(input, RoleSelectMenuBuilder); + this.data.components.push(resolveBuilder(input, RoleSelectMenuBuilder)); + return this; } /** @@ -156,7 +200,8 @@ export class ActionRowBuilder extends ComponentBuilder StringSelectMenuBuilder), ): this { - return this.sharedAddComponent(input, StringSelectMenuBuilder); + this.data.components.push(resolveBuilder(input, StringSelectMenuBuilder)); + return this; } /** @@ -167,7 +212,8 @@ export class ActionRowBuilder extends ComponentBuilder UserSelectMenuBuilder), ): this { - return this.sharedAddComponent(input, UserSelectMenuBuilder); + this.data.components.push(resolveBuilder(input, UserSelectMenuBuilder)); + return this; } /** @@ -178,7 +224,8 @@ export class ActionRowBuilder extends ComponentBuilder TextInputBuilder), ): this { - return this.sharedAddComponent(input, TextInputBuilder); + this.data.components.push(resolveBuilder(input, TextInputBuilder)); + return this; } /** @@ -198,17 +245,4 @@ export class ActionRowBuilder extends ComponentBuilder; } - - /** - * @internal - */ - private sharedAddComponent< - Component extends AnyActionRowComponentBuilder, - ComponentData extends Record, - >(component: Component | ComponentData | ((builder: Component) => Component), Constructor: new () => Component) { - const resolved = resolveBuilder(component, Constructor); - this.data.components.push(resolved); - - return this; - } } diff --git a/packages/builders/src/components/Components.ts b/packages/builders/src/components/Components.ts index 671d45815252..6ce718e963d2 100644 --- a/packages/builders/src/components/Components.ts +++ b/packages/builders/src/components/Components.ts @@ -1,8 +1,12 @@ -import { ComponentType, type APIMessageComponent, type APIModalComponent } from 'discord-api-types/v10'; +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 { ButtonBuilder } from './button/Button.js'; +import { CustomIdButtonBuilder } from './button/CustomIdButton.js'; +import { SKUIdButtonBuilder } from './button/SKUIdButton.js'; +import { URLButtonBuilder } from './button/URLButton.js'; import { ChannelSelectMenuBuilder } from './selectMenu/ChannelSelectMenu.js'; import { MentionableSelectMenuBuilder } from './selectMenu/MentionableSelectMenu.js'; import { RoleSelectMenuBuilder } from './selectMenu/RoleSelectMenu.js'; @@ -20,11 +24,16 @@ export type MessageComponentBuilder = ActionRowBuilder | MessageActionRowCompone */ export type ModalComponentBuilder = ActionRowBuilder | ModalActionRowComponentBuilder; +/** + * Any button builder + */ +export type AnyButtonBuilder = CustomIdButtonBuilder | SKUIdButtonBuilder | URLButtonBuilder; + /** * The builders that may be used within an action row for messages. */ export type MessageActionRowComponentBuilder = - | ButtonBuilder + | AnyButtonBuilder | ChannelSelectMenuBuilder | MentionableSelectMenuBuilder | RoleSelectMenuBuilder @@ -52,7 +61,7 @@ export interface MappedComponentTypes { /** * The button component type is associated with a {@link ButtonBuilder}. */ - [ComponentType.Button]: ButtonBuilder; + [ComponentType.Button]: AnyButtonBuilder; /** * The string select component type is associated with a {@link StringSelectMenuBuilder}. */ @@ -111,7 +120,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: @@ -129,3 +138,20 @@ export function createComponentBuilder( throw new Error(`Cannot properly serialize component type: ${data.type}`); } } + +function createButtonBuilder(data: APIButtonComponent): AnyButtonBuilder { + switch (data.style) { + case ButtonStyle.Primary: + case ButtonStyle.Secondary: + case ButtonStyle.Success: + case ButtonStyle.Danger: + return new CustomIdButtonBuilder(data); + case ButtonStyle.Link: + return new URLButtonBuilder(data); + case ButtonStyle.Premium: + return new SKUIdButtonBuilder(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}`); + } +} diff --git a/packages/builders/src/components/button/Button.ts b/packages/builders/src/components/button/Button.ts index b125366e9df8..0e3b177fcaf4 100644 --- a/packages/builders/src/components/button/Button.ts +++ b/packages/builders/src/components/button/Button.ts @@ -1,13 +1,4 @@ -import { - ComponentType, - type APIButtonComponent, - type APIButtonComponentWithCustomId, - type APIButtonComponentWithSKUId, - type APIButtonComponentWithURL, - type APIMessageComponentEmoji, - type ButtonStyle, - type Snowflake, -} from 'discord-api-types/v10'; +import type { APIButtonComponent } from 'discord-api-types/v10'; import { isValidationEnabled } from '../../util/validation.js'; import { buttonPredicate } from '../Assertions.js'; import { ComponentBuilder } from '../Component.js'; @@ -15,105 +6,8 @@ import { ComponentBuilder } from '../Component.js'; /** * A builder that creates API-compatible JSON data for buttons. */ -export class ButtonBuilder extends ComponentBuilder { - private readonly data: Partial; - - /** - * 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 = {}) { - super(); - this.data = { ...structuredClone(data), type: ComponentType.Button }; - } - - /** - * Sets the style of this button. - * - * @param style - The style to use - */ - public setStyle(style: ButtonStyle) { - this.data.style = 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 = 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 = 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).emoji = emoji; - return this; - } - - /** - * Clears the emoji on this button. - */ - public clearEmoji() { - (this.data as Exclude).emoji = undefined; - return this; - } +export abstract class ButtonBuilder extends ComponentBuilder { + protected declare readonly data: Partial; /** * Sets whether this button is disabled. @@ -125,34 +19,16 @@ export class ButtonBuilder extends ComponentBuilder { return this; } - /** - * Sets the label for this button. - * - * @param label - The label to use - */ - public setLabel(label: string) { - (this.data as Exclude).label = label; - return this; - } - - /** - * Clears the label on this button. - */ - public clearLabel() { - (this.data as Exclude).label = undefined; - return this; - } - /** * {@inheritDoc ComponentBuilder.toJSON} */ - public override toJSON(validationOverride?: boolean): APIButtonComponent { + public override toJSON(validationOverride?: boolean): ButtonData { const clone = structuredClone(this.data); if (validationOverride ?? isValidationEnabled()) { buttonPredicate.parse(clone); } - return clone as APIButtonComponent; + return clone as ButtonData; } } diff --git a/packages/builders/src/components/button/CustomIdButton.ts b/packages/builders/src/components/button/CustomIdButton.ts new file mode 100644 index 000000000000..0ca41174b029 --- /dev/null +++ b/packages/builders/src/components/button/CustomIdButton.ts @@ -0,0 +1,43 @@ +import { ComponentType, type APIButtonComponentWithCustomId } from 'discord-api-types/v10'; +import { Mixin } from 'ts-mixer'; +import { ButtonBuilder } from './Button.js'; +import { URLOrCustomIdButtonMixin } from './mixins/URLOrCustomIdButtonMixin.js'; + +export type CustomIdButtonStyle = APIButtonComponentWithCustomId['style']; + +/** + * A builder that creates API-compatible JSON data for buttons with custom IDs. + */ +export class CustomIdButtonBuilder extends Mixin( + ButtonBuilder, + URLOrCustomIdButtonMixin, +) { + protected override readonly data: Partial; + + public constructor(data: Partial = {}) { + super(); + this.data = { ...structuredClone(data), type: ComponentType.Button }; + } + + /** + * Sets the style of this button. + * + * @param style - The style to use + */ + public setStyle(style: CustomIdButtonStyle) { + this.data.style = style; + 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.custom_id = customId; + return this; + } +} diff --git a/packages/builders/src/components/button/SKUIdButton.ts b/packages/builders/src/components/button/SKUIdButton.ts new file mode 100644 index 000000000000..a4a9b9adc643 --- /dev/null +++ b/packages/builders/src/components/button/SKUIdButton.ts @@ -0,0 +1,26 @@ +import type { APIButtonComponentWithSKUId, Snowflake } from 'discord-api-types/v10'; +import { ButtonStyle, ComponentType } from 'discord-api-types/v10'; +import { ButtonBuilder } from './Button.js'; + +/** + * A builder that creates API-compatible JSON data for SKU ID buttons. + */ +export class SKUIdButtonBuilder extends ButtonBuilder { + protected override readonly data: Partial; + + public constructor(data: Partial = {}) { + 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; + } +} diff --git a/packages/builders/src/components/button/URLButton.ts b/packages/builders/src/components/button/URLButton.ts new file mode 100644 index 000000000000..6ffa5aa99a9b --- /dev/null +++ b/packages/builders/src/components/button/URLButton.ts @@ -0,0 +1,34 @@ +import { + ButtonStyle, + ComponentType, + type APIButtonComponent, + type APIButtonComponentWithURL, +} from 'discord-api-types/v10'; +import { Mixin } from 'ts-mixer'; +import { ButtonBuilder } from './Button.js'; +import { URLOrCustomIdButtonMixin } from './mixins/URLOrCustomIdButtonMixin.js'; + +/** + * A builder that creates API-compatible JSON data for buttons with URLs. + */ +export class URLButtonBuilder extends Mixin(ButtonBuilder, URLOrCustomIdButtonMixin) { + protected override readonly data: Partial; + + public constructor(data: Partial = {}) { + 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; + } +} diff --git a/packages/builders/src/components/button/mixins/URLOrCustomIdButtonMixin.ts b/packages/builders/src/components/button/mixins/URLOrCustomIdButtonMixin.ts new file mode 100644 index 000000000000..30826d67339b --- /dev/null +++ b/packages/builders/src/components/button/mixins/URLOrCustomIdButtonMixin.ts @@ -0,0 +1,44 @@ +import type { APIButtonComponent, APIButtonComponentWithSKUId, APIMessageComponentEmoji } from 'discord-api-types/v10'; + +export interface URLOrCustomIdButtonData + extends Pick, 'emoji' | 'label'> {} + +export class URLOrCustomIdButtonMixin { + protected declare readonly data: URLOrCustomIdButtonData; + + /** + * 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; + } +} diff --git a/packages/builders/src/index.ts b/packages/builders/src/index.ts index 16b6c3c76d2e..2fd4a00b3db7 100644 --- a/packages/builders/src/index.ts +++ b/packages/builders/src/index.ts @@ -1,4 +1,8 @@ +export * from './components/button/mixins/URLOrCustomIdButtonMixin.js'; export * from './components/button/Button.js'; +export * from './components/button/CustomIdButton.js'; +export * from './components/button/SKUIdButton.js'; +export * from './components/button/URLButton.js'; export * from './components/selectMenu/BaseSelectMenu.js'; export * from './components/selectMenu/ChannelSelectMenu.js';