feat: add components to /builders (#7195)

Co-authored-by: SpaceEEC <spaceeec@yahoo.com>
This commit is contained in:
Suneet Tipirneni
2022-01-12 14:50:08 -05:00
committed by GitHub
parent 37a22e04c2
commit 2bb40fd767
11 changed files with 777 additions and 0 deletions

View File

@@ -0,0 +1,96 @@
import { APIActionRowComponent, ButtonStyle, ComponentType } from 'discord-api-types/v9';
import { ActionRow, ButtonComponent, createComponent, SelectMenuComponent, SelectMenuOption } from '../../src';
describe('Action Row Components', () => {
describe('Assertion Tests', () => {
test('GIVEN valid components THEN do not throw', () => {
expect(() => new ActionRow().addComponents(new ButtonComponent())).not.toThrowError();
expect(() => new ActionRow().setComponents([new ButtonComponent()])).not.toThrowError();
});
test('GIVEN valid JSON input THEN valid JSON output is given', () => {
const actionRowData: APIActionRowComponent = {
type: ComponentType.ActionRow,
components: [
{
type: ComponentType.Button,
label: 'button',
style: ButtonStyle.Primary,
custom_id: 'test',
},
{
type: ComponentType.Button,
label: 'link',
style: ButtonStyle.Link,
url: 'https://google.com',
},
{
type: ComponentType.SelectMenu,
placeholder: 'test',
custom_id: 'test',
options: [
{
label: 'option',
value: 'option',
},
],
},
],
};
expect(new ActionRow(actionRowData).toJSON()).toEqual(actionRowData);
expect(new ActionRow().toJSON()).toEqual({ type: ComponentType.ActionRow, components: [] });
expect(() => createComponent({ type: ComponentType.ActionRow, components: [] })).not.toThrowError();
// @ts-expect-error
expect(() => createComponent({ type: 42, components: [] })).toThrowError();
});
test('GIVEN valid builder options THEN valid JSON output is given', () => {
const rowWithButtonData: APIActionRowComponent = {
type: ComponentType.ActionRow,
components: [
{
type: ComponentType.Button,
label: 'test',
custom_id: '123',
style: ButtonStyle.Primary,
},
],
};
const rowWithSelectMenuData: APIActionRowComponent = {
type: ComponentType.ActionRow,
components: [
{
type: ComponentType.SelectMenu,
custom_id: '1234',
options: [
{
label: 'one',
value: 'one',
},
{
label: 'two',
value: 'two',
},
],
max_values: 10,
min_values: 12,
},
],
};
const button = new ButtonComponent().setLabel('test').setStyle(ButtonStyle.Primary).setCustomId('123');
const selectMenu = new SelectMenuComponent()
.setCustomId('1234')
.setMaxValues(10)
.setMinValues(12)
.setOptions([
new SelectMenuOption().setLabel('one').setValue('one'),
new SelectMenuOption().setLabel('two').setValue('two'),
]);
expect(new ActionRow().addComponents(button).toJSON()).toEqual(rowWithButtonData);
expect(new ActionRow().addComponents(selectMenu).toJSON()).toEqual(rowWithSelectMenuData);
});
});
});

View File

@@ -0,0 +1,146 @@
import {
APIButtonComponentWithCustomId,
APIButtonComponentWithURL,
ButtonStyle,
ComponentType,
} from 'discord-api-types/v9';
import { buttonLabelValidator, buttonStyleValidator } from '../../src/components/Assertions';
import { ButtonComponent } from '../../src/components/Button';
const buttonComponent = () => new ButtonComponent();
const longStr =
'looooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong';
describe('Button Components', () => {
describe('Assertion Tests', () => {
test('GIVEN valid label THEN validator does not throw', () => {
expect(() => buttonLabelValidator.parse('foobar')).not.toThrowError();
});
test('GIVEN invalid label THEN validator does throw', () => {
expect(() => buttonLabelValidator.parse(null)).toThrowError();
expect(() => buttonLabelValidator.parse('')).toThrowError();
expect(() => buttonLabelValidator.parse(longStr)).toThrowError();
});
test('GIVEN valid style THEN validator does not throw', () => {
expect(() => buttonStyleValidator.parse(3)).not.toThrowError();
expect(() => buttonStyleValidator.parse(ButtonStyle.Secondary)).not.toThrowError();
});
test('GIVEN invalid style THEN validator does not throw', () => {
expect(() => buttonStyleValidator.parse(7)).toThrowError();
});
test('GIVEN valid fields THEN builder does not throw', () => {
expect(() =>
buttonComponent().setCustomId('custom').setStyle(ButtonStyle.Primary).setLabel('test'),
).not.toThrowError();
expect(() => {
const button = buttonComponent()
.setCustomId('custom')
.setStyle(ButtonStyle.Primary)
.setDisabled(true)
.setEmoji({ name: 'test' });
button.toJSON();
}).not.toThrowError();
expect(() => buttonComponent().setURL('https://google.com')).not.toThrowError();
});
test('GIVEN invalid fields THEN build does throw', () => {
expect(() => {
buttonComponent().setCustomId(longStr);
}).toThrowError();
expect(() => {
const button = buttonComponent()
.setCustomId('custom')
.setStyle(ButtonStyle.Primary)
.setDisabled(true)
.setLabel('test')
.setURL('https://google.com')
.setEmoji({ name: 'test' });
button.toJSON();
}).toThrowError();
expect(() => {
// @ts-expect-error
const button = buttonComponent().setEmoji('test');
button.toJSON();
}).toThrowError();
expect(() => {
const button = buttonComponent().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(() => buttonComponent().setStyle(24)).toThrowError();
expect(() => buttonComponent().setLabel(longStr)).toThrowError();
// @ts-expect-error
expect(() => buttonComponent().setDisabled(0)).toThrowError();
// @ts-expect-error
expect(() => buttonComponent().setEmoji('foo')).toThrowError();
expect(() => buttonComponent().setURL('foobar')).toThrowError();
});
test('GiVEN valid input THEN valid JSON outputs are given', () => {
const interactionData: APIButtonComponentWithCustomId = {
type: ComponentType.Button,
custom_id: 'test',
label: 'test',
style: ButtonStyle.Primary,
disabled: true,
};
expect(new ButtonComponent(interactionData).toJSON()).toEqual(interactionData);
expect(
buttonComponent()
.setCustomId(interactionData.custom_id)
.setLabel(interactionData.label)
.setStyle(interactionData.style)
.setDisabled(interactionData.disabled)
.toJSON(),
).toEqual(interactionData);
const linkData: APIButtonComponentWithURL = {
type: ComponentType.Button,
label: 'test',
style: ButtonStyle.Link,
disabled: true,
url: 'https://google.com',
};
expect(new ButtonComponent(linkData).toJSON()).toEqual(linkData);
expect(buttonComponent().setLabel(linkData.label).setDisabled(true).setURL(linkData.url));
});
});
});

View File

@@ -0,0 +1,72 @@
import { APISelectMenuComponent, APISelectMenuOption, ComponentType } from 'discord-api-types/v9';
import { SelectMenuComponent, SelectMenuOption } from '../../src/index';
const selectMenu = () => new SelectMenuComponent();
const selectMenuOption = () => new SelectMenuOption();
const longStr =
'looooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong';
describe('Button Components', () => {
describe('Assertion Tests', () => {
test('GIVEN valid inputs THEN Select Menu does not throw', () => {
expect(() => selectMenu().setCustomId('foo')).not.toThrowError();
expect(() => selectMenu().setMaxValues(10)).not.toThrowError();
expect(() => selectMenu().setMinValues(3)).not.toThrowError();
expect(() => selectMenu().setDisabled(true)).not.toThrowError();
expect(() => selectMenu().setPlaceholder('description')).not.toThrowError();
const option = selectMenuOption()
.setLabel('test')
.setValue('test')
.setDefault(true)
.setEmoji({ name: 'test' })
.setDescription('description');
expect(() => selectMenu().addOptions(option)).not.toThrowError();
expect(() => selectMenu().setOptions([option])).not.toThrowError();
});
test('GIVEN invalid inputs THEN Select Menu does throw', () => {
expect(() => selectMenu().setCustomId(longStr)).toThrowError();
expect(() => selectMenu().setMaxValues(30)).toThrowError();
expect(() => selectMenu().setMinValues(-20)).toThrowError();
// @ts-expect-error
expect(() => selectMenu().setDisabled(0)).toThrowError();
expect(() => selectMenu().setPlaceholder(longStr)).toThrowError();
expect(() => {
selectMenuOption()
.setLabel(longStr)
.setValue(longStr)
// @ts-expect-error
.setDefault(-1)
// @ts-expect-error
.setEmoji({ name: 1 })
.setDescription(longStr);
}).toThrowError();
});
test('GIVEN valid JSON input THEN valid JSON history is correct', () => {
const selectMenuOptionData: APISelectMenuOption = {
label: 'test',
value: 'test',
emoji: { name: 'test' },
default: true,
description: 'test',
};
const selectMenuData: APISelectMenuComponent = {
type: ComponentType.SelectMenu,
custom_id: 'test',
max_values: 10,
min_values: 3,
disabled: true,
options: [selectMenuOptionData],
placeholder: 'test',
};
expect(new SelectMenuComponent(selectMenuData).toJSON()).toEqual(selectMenuData);
expect(new SelectMenuOption(selectMenuOptionData).toJSON()).toEqual(selectMenuOptionData);
});
});
});

View File

@@ -0,0 +1,46 @@
import { APIActionRowComponent, ComponentType } from 'discord-api-types/v9';
import type { ButtonComponent, SelectMenuComponent } from '..';
import type { Component } from './Component';
import { createComponent } from './Components';
export type ActionRowComponent = ButtonComponent | SelectMenuComponent;
// TODO: Add valid form component types
/**
* Represents an action row component
*/
export class ActionRow<T extends ActionRowComponent> implements Component {
public readonly components: T[] = [];
public readonly type = ComponentType.ActionRow;
public constructor(data?: APIActionRowComponent) {
this.components = (data?.components.map(createComponent) ?? []) as T[];
}
/**
* Adds components to this action row.
* @param components The components to add to this action row.
* @returns
*/
public addComponents(...components: T[]) {
this.components.push(...components);
return this;
}
/**
* Sets the components in this action row
* @param components The components to set this row to
*/
public setComponents(components: T[]) {
Reflect.set(this, 'components', [...components]);
return this;
}
public toJSON(): APIActionRowComponent {
return {
...this,
components: this.components.map((component) => component.toJSON()),
};
}
}

View File

@@ -0,0 +1,64 @@
import { APIMessageComponentEmoji, ButtonStyle } from 'discord-api-types/v9';
import { z } from 'zod';
import type { SelectMenuOption } from './selectMenu/SelectMenuOption';
export const customIdValidator = z.string().min(1).max(100);
export const emojiValidator = z
.object({
id: z.string(),
name: z.string(),
animated: z.boolean(),
})
.partial()
.strict();
export const disabledValidator = z.boolean();
export const buttonLabelValidator = z.string().nonempty().max(80);
export const buttonStyleValidator = z.number().int().min(ButtonStyle.Primary).max(ButtonStyle.Link);
export const placeholderValidator = z.string().max(100);
export const minMaxValidator = z.number().int().min(0).max(25);
export const optionsValidator = z.object({}).array().nonempty();
export function validateRequiredSelectMenuParameters(options: SelectMenuOption[], customId?: string) {
customIdValidator.parse(customId);
optionsValidator.parse(options);
}
export const labelValueValidator = z.string().min(1).max(100);
export const defaultValidator = z.boolean();
export function validateRequiredSelectMenuOptionParameters(label?: string, value?: string) {
labelValueValidator.parse(label);
labelValueValidator.parse(value);
}
export const urlValidator = z.string().url();
export function validateRequiredButtonParameters(
style: ButtonStyle,
label?: string,
emoji?: APIMessageComponentEmoji,
customId?: string,
url?: string,
) {
if (url && customId) {
throw new RangeError('URL and custom id are mutually exclusive');
}
if (!label && !emoji) {
throw new RangeError('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-link buttons cannot have a url');
}
}

View File

@@ -0,0 +1,105 @@
import { APIButtonComponent, APIMessageComponentEmoji, ButtonStyle, ComponentType } from 'discord-api-types/v9';
import {
buttonLabelValidator,
buttonStyleValidator,
customIdValidator,
disabledValidator,
emojiValidator,
urlValidator,
validateRequiredButtonParameters,
} from './Assertions';
import type { Component } from './Component';
export class ButtonComponent implements Component {
public readonly type = ComponentType.Button as const;
public readonly style!: ButtonStyle;
public readonly label?: string;
public readonly emoji?: APIMessageComponentEmoji;
public readonly disabled?: boolean;
public readonly custom_id!: string;
public readonly url!: string;
public constructor(data?: APIButtonComponent) {
/* eslint-disable @typescript-eslint/non-nullable-type-assertion-style */
this.style = data?.style as ButtonStyle;
this.label = data?.label;
this.emoji = data?.emoji;
this.disabled = data?.disabled;
// This if/else makes typescript happy
if (data?.style === ButtonStyle.Link) {
this.url = data.url;
} else {
this.custom_id = data?.custom_id as string;
}
/* eslint-enable @typescript-eslint/non-nullable-type-assertion-style */
}
/**
* Sets the style of this button
* @param style The style of the button
*/
public setStyle(style: ButtonStyle) {
buttonStyleValidator.parse(style);
Reflect.set(this, 'style', style);
return this;
}
/**
* Sets the URL for this button
* @param url The URL to open when this button is clicked
*/
public setURL(url: string) {
urlValidator.parse(url);
Reflect.set(this, 'url', url);
return this;
}
/**
* Sets the custom Id for this button
* @param customId The custom ID to use for this button
*/
public setCustomId(customId: string) {
customIdValidator.parse(customId);
Reflect.set(this, 'custom_id', customId);
return this;
}
/**
* Sets the emoji to display on this button
* @param emoji The emoji to display on this button
*/
public setEmoji(emoji: APIMessageComponentEmoji) {
emojiValidator.parse(emoji);
Reflect.set(this, 'emoji', emoji);
return this;
}
/**
* Sets whether this button is disable or not
* @param disabled Whether or not to disable this button or not
*/
public setDisabled(disabled: boolean) {
disabledValidator.parse(disabled);
Reflect.set(this, 'disabled', disabled);
return this;
}
/**
* Sets the label for this button
* @param label The label to display on this button
*/
public setLabel(label: string) {
buttonLabelValidator.parse(label);
Reflect.set(this, 'label', label);
return this;
}
public toJSON(): APIButtonComponent {
validateRequiredButtonParameters(this.style, this.label, this.emoji, this.custom_id, this.url);
return {
...this,
};
}
}

View File

@@ -0,0 +1,15 @@
import type { APIMessageComponent, ComponentType } from 'discord-api-types/v9';
/**
* Represents a discord component
*/
export interface Component {
/**
* The type of this component
*/
readonly type: ComponentType;
/**
* Converts this component to an API-compatible JSON object
*/
toJSON: () => APIMessageComponent;
}

View File

@@ -0,0 +1,30 @@
import { APIMessageComponent, ComponentType } from 'discord-api-types/v9';
import { ActionRow, ButtonComponent, Component, SelectMenuComponent } from '../index';
import type { ActionRowComponent } from './ActionRow';
export interface MappedComponentTypes {
[ComponentType.ActionRow]: ActionRow<ActionRowComponent>;
[ComponentType.Button]: ButtonComponent;
[ComponentType.SelectMenu]: SelectMenuComponent;
}
/**
* Factory for creating components from API data
* @param data The api data to transform to a component class
*/
export function createComponent<T extends keyof MappedComponentTypes>(
data: APIMessageComponent & { type: T },
): MappedComponentTypes[T];
export function createComponent(data: APIMessageComponent): Component {
switch (data.type) {
case ComponentType.ActionRow:
return new ActionRow(data);
case ComponentType.Button:
return new ButtonComponent(data);
case ComponentType.SelectMenu:
return new SelectMenuComponent(data);
default:
// @ts-expect-error
throw new Error(`Cannot serialize component type: ${data.type as number}`);
}
}

View File

@@ -0,0 +1,111 @@
import { APISelectMenuComponent, ComponentType } from 'discord-api-types/v9';
import {
customIdValidator,
disabledValidator,
minMaxValidator,
placeholderValidator,
validateRequiredSelectMenuParameters,
} from '../Assertions';
import type { Component } from '../Component';
import { SelectMenuOption } from './SelectMenuOption';
/**
* Represents a select menu component
*/
export class SelectMenuComponent implements Component {
public readonly type = ComponentType.SelectMenu as const;
public readonly options: SelectMenuOption[];
public readonly placeholder?: string;
public readonly min_values?: number;
public readonly max_values?: number;
public readonly custom_id!: string;
public readonly disabled?: boolean;
public constructor(data?: APISelectMenuComponent) {
this.options = data?.options.map((option) => new SelectMenuOption(option)) ?? [];
this.placeholder = data?.placeholder;
this.min_values = data?.min_values;
this.max_values = data?.max_values;
/* eslint-disable @typescript-eslint/non-nullable-type-assertion-style */
this.custom_id = data?.custom_id as string;
/* eslint-enable @typescript-eslint/non-nullable-type-assertion-style */
this.disabled = data?.disabled;
}
/**
* Sets the placeholder for this select menu
* @param placeholder The placeholder to use for this select menu
*/
public setPlaceholder(placeholder: string) {
placeholderValidator.parse(placeholder);
Reflect.set(this, 'placeholder', placeholder);
return this;
}
/**
* Sets thes minimum values that must be selected in the select menu
* @param minValues The minimum values that must be selected
*/
public setMinValues(minValues: number) {
minMaxValidator.parse(minValues);
Reflect.set(this, 'min_values', minValues);
return this;
}
/**
* Sets thes maximum values that must be selected in the select menu
* @param minValues The maximum values that must be selected
*/
public setMaxValues(maxValues: number) {
minMaxValidator.parse(maxValues);
Reflect.set(this, 'max_values', maxValues);
return this;
}
/**
* Sets the custom Id for this select menu
* @param customId The custom ID to use for this select menu
*/
public setCustomId(customId: string) {
customIdValidator.parse(customId);
Reflect.set(this, 'custom_id', customId);
return this;
}
/**
* Sets whether or not this select menu is disabled
* @param disabled Whether or not this select menu is disabled
*/
public setDisabled(disabled: boolean) {
disabledValidator.parse(disabled);
Reflect.set(this, 'disabled', disabled);
return this;
}
/**
* Adds options to this select menu
* @param options The options to add to this select menu
* @returns
*/
public addOptions(...options: SelectMenuOption[]) {
this.options.push(...options);
return this;
}
/**
* Sets the options on this select menu
* @param options The options to set on this select menu
*/
public setOptions(options: SelectMenuOption[]) {
Reflect.set(this, 'options', [...options]);
return this;
}
public toJSON(): APISelectMenuComponent {
validateRequiredSelectMenuParameters(this.options, this.custom_id);
return {
...this,
options: this.options.map((option) => option.toJSON()),
};
}
}

View File

@@ -0,0 +1,83 @@
import type { APIMessageComponentEmoji, APISelectMenuOption } from 'discord-api-types/v9';
import {
defaultValidator,
emojiValidator,
labelValueValidator,
validateRequiredSelectMenuOptionParameters,
} from '../Assertions';
/**
* Represents an option within a select menu component
*/
export class SelectMenuOption {
public readonly label!: string;
public readonly value!: string;
public readonly description?: string;
public readonly emoji?: APIMessageComponentEmoji;
public readonly default?: boolean;
public constructor(data?: APISelectMenuOption) {
/* eslint-disable @typescript-eslint/non-nullable-type-assertion-style */
this.label = data?.label as string;
this.value = data?.value as string;
/* eslint-enable @typescript-eslint/non-nullable-type-assertion-style */
this.description = data?.description;
this.emoji = data?.emoji;
this.default = data?.default;
}
/**
* Sets the label of this option
* @param label The label to show on this option
*/
public setLabel(label: string) {
Reflect.set(this, 'label', label);
return this;
}
/**
* Sets the value of this option
* @param value The value of this option
*/
public setValue(value: string) {
Reflect.set(this, 'value', value);
return this;
}
/**
* Sets the description of this option.
* @param description The description of this option
*/
public setDescription(description: string) {
labelValueValidator.parse(description);
Reflect.set(this, 'description', description);
return this;
}
/**
* Sets whether this option is selected by default
* @param isDefault Whether or not this option is selected by default
*/
public setDefault(isDefault: boolean) {
defaultValidator.parse(isDefault);
Reflect.set(this, 'default', isDefault);
return this;
}
/**
* Sets the emoji to display on this button
* @param emoji The emoji to display on this button
*/
public setEmoji(emoji: APIMessageComponentEmoji) {
emojiValidator.parse(emoji);
Reflect.set(this, 'emoji', emoji);
return this;
}
public toJSON(): APISelectMenuOption {
validateRequiredSelectMenuOptionParameters(this.label, this.value);
return {
...this,
};
}
}

View File

@@ -2,6 +2,15 @@ export * as EmbedAssertions from './messages/embed/Assertions';
export * from './messages/embed/Embed';
export * from './messages/formatters';
export * as ComponentAssertions from './components/Assertions';
export * from './components/ActionRow';
export * from './components/Button';
export * from './components/Component';
export * from './components/Components';
export * from './components/Button';
export * from './components/selectMenu/SelectMenu';
export * from './components/selectMenu/SelectMenuOption';
export * as SlashCommandAssertions from './interactions/slashCommands/Assertions';
export * from './interactions/slashCommands/SlashCommandBuilder';
export * from './interactions/slashCommands/SlashCommandSubcommands';