Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add components to /builders #7195

Merged
merged 8 commits into from
Jan 12, 2022
96 changes: 96 additions & 0 deletions packages/builders/__tests__/components/actionRow.test.ts
Original file line number Diff line number Diff line change
@@ -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();
suneettipirneni marked this conversation as resolved.
Show resolved Hide resolved
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);
});
});
});
146 changes: 146 additions & 0 deletions packages/builders/__tests__/components/button.test.ts
Original file line number Diff line number Diff line change
@@ -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));
});
});
});
72 changes: 72 additions & 0 deletions packages/builders/__tests__/components/selectMenu.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
46 changes: 46 additions & 0 deletions packages/builders/src/components/ActionRow.ts
Original file line number Diff line number Diff line change
@@ -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()),
};
}
}