Skip to content

Commit

Permalink
feat: Add Modals and Text Inputs (#7023)
Browse files Browse the repository at this point in the history
Co-authored-by: Vlad Frangu <kingdgrizzle@gmail.com>
Co-authored-by: Jiralite <33201955+Jiralite@users.noreply.github.com>
Co-authored-by: Ryan Munro <monbrey@gmail.com>
Co-authored-by: Vitor <milagre.vitor@gmail.com>
  • Loading branch information
5 people committed Mar 4, 2022
1 parent 53defb8 commit ed92015
Show file tree
Hide file tree
Showing 31 changed files with 1,075 additions and 115 deletions.
45 changes: 42 additions & 3 deletions packages/builders/__tests__/components/actionRow.test.ts
@@ -1,4 +1,10 @@
import { APIActionRowComponent, APIMessageComponent, ButtonStyle, ComponentType } from 'discord-api-types/v9';
import {
APIActionRowComponent,
APIActionRowComponentTypes,
APIMessageActionRowComponent,
ButtonStyle,
ComponentType,
} from 'discord-api-types/v9';
import { ActionRow, ButtonComponent, createComponent, SelectMenuComponent, SelectMenuOption } from '../../src';

const rowWithButtonData: APIActionRowComponent<APIMessageComponent> = {
Expand Down Expand Up @@ -43,7 +49,7 @@ describe('Action Row Components', () => {
});

test('GIVEN valid JSON input THEN valid JSON output is given', () => {
const actionRowData: APIActionRowComponent<APIMessageComponent> = {
const actionRowData: APIActionRowComponent<APIMessageActionRowComponent> = {
type: ComponentType.ActionRow,
components: [
{
Expand Down Expand Up @@ -75,10 +81,43 @@ describe('Action Row Components', () => {
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<APIActionRowComponentTypes> = {
type: ComponentType.ActionRow,
components: [
{
type: ComponentType.Button,
label: 'test',
custom_id: '123',
style: ButtonStyle.Primary,
},
],
};

const rowWithSelectMenuData: APIActionRowComponent<APIActionRowComponentTypes> = {
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')
Expand Down
126 changes: 126 additions & 0 deletions packages/builders/__tests__/components/textInput.test.ts
@@ -0,0 +1,126 @@
import { APITextInputComponent, ComponentType, TextInputStyle } from 'discord-api-types/v9';
import {
labelValidator,
maxLengthValidator,
minLengthValidator,
placeholderValidator,
valueValidator,
textInputStyleValidator,
} from '../../src/components/textInput/Assertions';
import { TextInputComponent } from '../../src/components/textInput/TextInput';

const superLongStr = 'a'.repeat(5000);

const textInputComponent = () => new TextInputComponent();

describe('Text Input Components', () => {
describe('Assertion Tests', () => {
test('GIVEN valid label THEN validator does not throw', () => {
expect(() => labelValidator.parse('foobar')).not.toThrowError();
});

test('GIVEN invalid label THEN validator does throw', () => {
expect(() => labelValidator.parse(24)).toThrowError();
expect(() => labelValidator.parse(undefined)).toThrowError();
});

test('GIVEN valid style THEN validator does not throw', () => {
expect(() => textInputStyleValidator.parse(TextInputStyle.Paragraph)).not.toThrowError();
expect(() => textInputStyleValidator.parse(TextInputStyle.Short)).not.toThrowError();
});

test('GIVEN invalid style THEN validator does throw', () => {
expect(() => textInputStyleValidator.parse(24)).toThrowError();
});

test('GIVEN valid min length THEN validator does not throw', () => {
expect(() => minLengthValidator.parse(10)).not.toThrowError();
});

test('GIVEN invalid min length THEN validator does throw', () => {
expect(() => minLengthValidator.parse(-1)).toThrowError();
});

test('GIVEN valid max length THEN validator does not throw', () => {
expect(() => maxLengthValidator.parse(10)).not.toThrowError();
});

test('GIVEN invalid min length THEN validator does throw', () => {
expect(() => maxLengthValidator.parse(4001)).toThrowError();
});

test('GIVEN valid value THEN validator does not throw', () => {
expect(() => valueValidator.parse('foobar')).not.toThrowError();
});

test('GIVEN invalid value THEN validator does throw', () => {
expect(() => valueValidator.parse(superLongStr)).toThrowError();
});

test('GIVEN valid placeholder THEN validator does not throw', () => {
expect(() => placeholderValidator.parse('foobar')).not.toThrowError();
});

test('GIVEN invalid value THEN validator does throw', () => {
expect(() => placeholderValidator.parse(superLongStr)).toThrowError();
});

test('GIVEN valid fields THEN builder does not throw', () => {
expect(() => {
textInputComponent().setCustomId('foobar').setLabel('test').setStyle(TextInputStyle.Paragraph).toJSON();
}).not.toThrowError();

expect(() => {
textInputComponent()
.setCustomId('foobar')
.setLabel('test')
.setMaxLength(100)
.setMinLength(1)
.setPlaceholder('bar')
.setRequired(true)
.setStyle(TextInputStyle.Paragraph)
.toJSON();
}).not.toThrowError();
});
});

test('GIVEN invalid fields THEN builder throws', () => {
expect(() => textInputComponent().toJSON()).toThrowError();
expect(() => {
textInputComponent()
.setCustomId('test')
.setMaxLength(100)
.setPlaceholder('hello')
.setStyle(TextInputStyle.Paragraph)
.toJSON();
}).toThrowError();
});

test('GIVEN valid input THEN valid JSON outputs are given', () => {
const textInputData: APITextInputComponent = {
type: ComponentType.TextInput,
label: 'label',
custom_id: 'custom id',
placeholder: 'placeholder',
max_length: 100,
min_length: 10,
value: 'value',
required: false,
style: TextInputStyle.Paragraph,
};

expect(new TextInputComponent(textInputData).toJSON()).toEqual(textInputData);
expect(
textInputComponent()
.setCustomId(textInputData.custom_id)
.setLabel(textInputData.label)
.setPlaceholder(textInputData.placeholder)
.setMaxLength(textInputData.max_length)
.setMinLength(textInputData.min_length)
.setValue(textInputData.value)
.setRequired(textInputData.required)
.setStyle(textInputData.style)
.toJSON(),
).toEqual(textInputData);
});
});
88 changes: 88 additions & 0 deletions packages/builders/__tests__/interactions/modal.test.ts
@@ -0,0 +1,88 @@
import { APIModalInteractionResponseCallbackData, ComponentType, TextInputStyle } from 'discord-api-types/v9';
import { ActionRow, ButtonComponent, Modal, ModalActionRowComponent, TextInputComponent } from '../../src';
import {
componentsValidator,
titleValidator,
validateRequiredParameters,
} from '../../src/interactions/modals/Assertions';

const modal = () => new Modal();

describe('Modals', () => {
describe('Assertion Tests', () => {
test('GIVEN valid title THEN validator does not throw', () => {
expect(() => titleValidator.parse('foobar')).not.toThrowError();
});

test('GIVEN invalid title THEN validator does throw', () => {
expect(() => titleValidator.parse(42)).toThrowError();
});

test('GIVEN valid components THEN validator does not throw', () => {
expect(() => componentsValidator.parse([new ActionRow(), new ActionRow()])).not.toThrowError();
});

test('GIVEN invalid components THEN validator does throw', () => {
expect(() => componentsValidator.parse([new ButtonComponent(), new TextInputComponent()])).toThrowError();
});

test('GIVEN valid required parameters THEN validator does not throw', () => {
expect(() => validateRequiredParameters('123', 'title', [new ActionRow(), new ActionRow()])).not.toThrowError();
});

test('GIVEN invalid required parameters THEN validator does throw', () => {
expect(() =>
// @ts-expect-error
validateRequiredParameters('123', undefined, [new ActionRow(), new ButtonComponent()]),
).toThrowError();
});
});

test('GIVEN valid fields THEN builder does not throw', () => {
expect(() => modal().setTitle('test').setCustomId('foobar').setComponents(new ActionRow())).not.toThrowError();
});

test('GIVEN invalid fields THEN builder does throw', () => {
expect(() =>
// @ts-expect-error
modal().setTitle('test').setCustomId('foobar').setComponents([new ActionRow()]).toJSON(),
).toThrowError();
expect(() => modal().setTitle('test').setCustomId('foobar').toJSON()).toThrowError();
// @ts-expect-error
expect(() => modal().setTitle('test').setCustomId(42).toJSON()).toThrowError();
});

test('GIVEN valid input THEN valid JSON outputs are given', () => {
const modalData: APIModalInteractionResponseCallbackData = {
title: 'title',
custom_id: 'custom id',
components: [
{
type: ComponentType.ActionRow,
components: [
{
type: ComponentType.TextInput,
label: 'label',
style: TextInputStyle.Paragraph,
custom_id: 'custom id',
},
],
},
],
};

expect(new Modal(modalData).toJSON()).toEqual(modalData);

expect(
modal()
.setTitle(modalData.title)
.setCustomId('custom id')
.setComponents(
new ActionRow<ModalActionRowComponent>().addComponents(
new TextInputComponent().setCustomId('custom id').setLabel('label').setStyle(TextInputStyle.Paragraph),
),
)
.toJSON(),
).toEqual(modalData);
});
});
2 changes: 1 addition & 1 deletion packages/builders/package.json
Expand Up @@ -52,7 +52,7 @@
"homepage": "https://discord.js.org",
"dependencies": {
"@sindresorhus/is": "^4.4.0",
"discord-api-types": "^0.27.0",
"discord-api-types": "^0.27.3",
"fast-deep-equal": "^3.1.3",
"ts-mixer": "^6.0.0",
"tslib": "^2.3.1",
Expand Down
38 changes: 27 additions & 11 deletions packages/builders/src/components/ActionRow.ts
@@ -1,26 +1,42 @@
import { type APIActionRowComponent, ComponentType, APIMessageComponent } from 'discord-api-types/v9';
import type { ButtonComponent, SelectMenuComponent } from '..';
import {
APIActionRowComponent,
APIMessageActionRowComponent,
APIModalActionRowComponent,
ComponentType,
} from 'discord-api-types/v9';
import type { ButtonComponent, SelectMenuComponent, TextInputComponent } from '../index';
import { Component } from './Component';
import { createComponent } from './Components';
import isEqual from 'fast-deep-equal';

export type MessageComponent = ActionRowComponent | ActionRow;
export type MessageComponent = MessageActionRowComponent | ActionRow<MessageActionRowComponent>;
export type ModalComponent = ModalActionRowComponent | ActionRow<ModalActionRowComponent>;

export type ActionRowComponent = ButtonComponent | SelectMenuComponent;
export type MessageActionRowComponent = ButtonComponent | SelectMenuComponent;
export type ModalActionRowComponent = TextInputComponent;

// TODO: Add valid form component types
/**
* Represents an action row component
*/
export class ActionRow<T extends ActionRowComponent = ActionRowComponent> extends Component<
Omit<Partial<APIActionRowComponent<APIMessageComponent>> & { type: ComponentType.ActionRow }, 'components'>
export class ActionRow<
T extends ModalActionRowComponent | MessageActionRowComponent = ModalActionRowComponent | MessageActionRowComponent,
> extends Component<
Omit<
Partial<APIActionRowComponent<APIMessageActionRowComponent | APIModalActionRowComponent>> & {
type: ComponentType.ActionRow;
},
'components'
>
> {
/**
* The components within this action row
*/
public readonly components: T[];

public constructor({ components, ...data }: Partial<APIActionRowComponent<APIMessageComponent>> = {}) {
public constructor({
components,
...data
}: Partial<APIActionRowComponent<APIMessageActionRowComponent | APIModalActionRowComponent>> = {}) {
super({ type: ComponentType.ActionRow, ...data });
this.components = (components?.map((c) => createComponent(c)) ?? []) as T[];
}
Expand All @@ -44,14 +60,14 @@ export class ActionRow<T extends ActionRowComponent = ActionRowComponent> extend
return this;
}

public toJSON(): APIActionRowComponent<APIMessageComponent> {
public toJSON(): APIActionRowComponent<ReturnType<T['toJSON']>> {
return {
...this.data,
components: this.components.map((component) => component.toJSON()),
components: this.components.map((component) => component.toJSON()) as ReturnType<T['toJSON']>[],
};
}

public equals(other: APIActionRowComponent<APIMessageComponent> | ActionRow) {
public equals(other: APIActionRowComponent<APIMessageActionRowComponent | APIModalActionRowComponent> | ActionRow) {
if (other instanceof ActionRow) {
return isEqual(other.data, this.data) && isEqual(other.components, this.components);
}
Expand Down

0 comments on commit ed92015

Please sign in to comment.