Skip to content

Commit

Permalink
feat: new select menus (#8793)
Browse files Browse the repository at this point in the history
* feat(builders): new select menus

* chore: better re-exporting of deprecated classes

* feat: new select menus

* chore: typings

* chore: add missing todo comment

* chore: finish updating tests

* chore: add runtime deprecation warnings

* chore: format deprecation warning

* feat(BaseInteraction): isAnySelectMenu

* chore: requested changes

* fix: deprecation comments

* chore: update @deprecated comments in typings

* chore: add tests for select menu type narrowing

* fix: bad auto imports

Co-authored-by: Julian Vennen <julian@aternos.org>

* fix: properly handle resolved members

* fix: collectors

* chore: suggested changes

Co-authored-by: Almeida <almeidx@pm.me>

* fix(typings): bad class extends

* feat(ChannelSelectMenuBuilder): validation

* chore: update todo comment

* refactor(ChannelSelectMenu): better handling of channel_types state

* chore: style nit

* chore: suggested nits

Co-authored-by: Aura Román <kyradiscord@gmail.com>

Co-authored-by: Julian Vennen <julian@aternos.org>
Co-authored-by: Almeida <almeidx@pm.me>
Co-authored-by: Aura Román <kyradiscord@gmail.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
  • Loading branch information
5 people committed Nov 1, 2022
1 parent 8b400ca commit 5152abf
Show file tree
Hide file tree
Showing 50 changed files with 1,530 additions and 471 deletions.
20 changes: 10 additions & 10 deletions packages/builders/__tests__/components/actionRow.test.ts
Expand Up @@ -9,8 +9,8 @@ import {
ActionRowBuilder,
ButtonBuilder,
createComponentBuilder,
SelectMenuBuilder,
SelectMenuOptionBuilder,
StringSelectMenuBuilder,
StringSelectMenuOptionBuilder,
} from '../../src/index.js';

const rowWithButtonData: APIActionRowComponent<APIMessageActionRowComponent> = {
Expand All @@ -29,7 +29,7 @@ const rowWithSelectMenuData: APIActionRowComponent<APIMessageActionRowComponent>
type: ComponentType.ActionRow,
components: [
{
type: ComponentType.SelectMenu,
type: ComponentType.StringSelect,
custom_id: '1234',
options: [
{
Expand Down Expand Up @@ -73,7 +73,7 @@ describe('Action Row Components', () => {
url: 'https://google.com',
},
{
type: ComponentType.SelectMenu,
type: ComponentType.StringSelect,
placeholder: 'test',
custom_id: 'test',
options: [
Expand Down Expand Up @@ -108,7 +108,7 @@ describe('Action Row Components', () => {
type: ComponentType.ActionRow,
components: [
{
type: ComponentType.SelectMenu,
type: ComponentType.StringSelect,
custom_id: '1234',
options: [
{
Expand All @@ -134,17 +134,17 @@ 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 selectMenu = new SelectMenuBuilder()
const selectMenu = new StringSelectMenuBuilder()
.setCustomId('1234')
.setMaxValues(10)
.setMinValues(12)
.setOptions(
new SelectMenuOptionBuilder().setLabel('one').setValue('one'),
new SelectMenuOptionBuilder().setLabel('two').setValue('two'),
new StringSelectMenuOptionBuilder().setLabel('one').setValue('one'),
new StringSelectMenuOptionBuilder().setLabel('two').setValue('two'),
)
.setOptions([
new SelectMenuOptionBuilder().setLabel('one').setValue('one'),
new SelectMenuOptionBuilder().setLabel('two').setValue('two'),
new StringSelectMenuOptionBuilder().setLabel('one').setValue('one'),
new StringSelectMenuOptionBuilder().setLabel('two').setValue('two'),
]);

expect(new ActionRowBuilder().addComponents(button).toJSON()).toEqual(rowWithButtonData);
Expand Down
10 changes: 5 additions & 5 deletions packages/builders/__tests__/components/components.test.ts
Expand Up @@ -13,12 +13,12 @@ import {
ActionRowBuilder,
ButtonBuilder,
createComponentBuilder,
SelectMenuBuilder,
StringSelectMenuBuilder,
TextInputBuilder,
} from '../../src/index.js';

describe('createComponentBuilder', () => {
test.each([ButtonBuilder, SelectMenuBuilder, TextInputBuilder])(
test.each([ButtonBuilder, StringSelectMenuBuilder, TextInputBuilder])(
'passing an instance of %j should return itself',
(Builder) => {
const builder = new Builder();
Expand All @@ -45,14 +45,14 @@ describe('createComponentBuilder', () => {
expect(createComponentBuilder(button)).toBeInstanceOf(ButtonBuilder);
});

test('GIVEN a select menu component THEN returns a SelectMenuBuilder', () => {
test('GIVEN a select menu component THEN returns a StringSelectMenuBuilder', () => {
const selectMenu: APISelectMenuComponent = {
custom_id: 'abc',
options: [],
type: ComponentType.SelectMenu,
type: ComponentType.StringSelect,
};

expect(createComponentBuilder(selectMenu)).toBeInstanceOf(SelectMenuBuilder);
expect(createComponentBuilder(selectMenu)).toBeInstanceOf(StringSelectMenuBuilder);
});

test('GIVEN a text input component THEN returns a TextInputBuilder', () => {
Expand Down
16 changes: 8 additions & 8 deletions packages/builders/__tests__/components/selectMenu.test.ts
@@ -1,9 +1,9 @@
import { ComponentType, type APISelectMenuComponent, type APISelectMenuOption } from 'discord-api-types/v10';
import { describe, test, expect } from 'vitest';
import { SelectMenuBuilder, SelectMenuOptionBuilder } from '../../src/index.js';
import { StringSelectMenuBuilder, StringSelectMenuOptionBuilder } from '../../src/index.js';

const selectMenu = () => new SelectMenuBuilder();
const selectMenuOption = () => new SelectMenuOptionBuilder();
const selectMenu = () => new StringSelectMenuBuilder();
const selectMenuOption = () => new StringSelectMenuOptionBuilder();

const longStr = 'a'.repeat(256);

Expand Down Expand Up @@ -165,16 +165,16 @@ describe('Select Menu Components', () => {

test('GIVEN valid JSON input THEN valid JSON history is correct', () => {
expect(
new SelectMenuBuilder(selectMenuDataWithoutOptions)
.addOptions(new SelectMenuOptionBuilder(selectMenuOptionData))
new StringSelectMenuBuilder(selectMenuDataWithoutOptions)
.addOptions(new StringSelectMenuOptionBuilder(selectMenuOptionData))
.toJSON(),
).toEqual(selectMenuData);
expect(
new SelectMenuBuilder(selectMenuDataWithoutOptions)
.addOptions([new SelectMenuOptionBuilder(selectMenuOptionData)])
new StringSelectMenuBuilder(selectMenuDataWithoutOptions)
.addOptions([new StringSelectMenuOptionBuilder(selectMenuOptionData)])
.toJSON(),
).toEqual(selectMenuData);
expect(new SelectMenuOptionBuilder(selectMenuOptionData).toJSON()).toEqual(selectMenuOptionData);
expect(new StringSelectMenuOptionBuilder(selectMenuOptionData).toJSON()).toEqual(selectMenuOptionData);
});
});
});
2 changes: 1 addition & 1 deletion packages/builders/package.json
Expand Up @@ -56,7 +56,7 @@
"dependencies": {
"@discordjs/util": "workspace:^",
"@sapphire/shapeshift": "^3.7.0",
"discord-api-types": "^0.37.14",
"discord-api-types": "^0.37.15",
"fast-deep-equal": "^3.1.3",
"ts-mixer": "^6.0.1",
"tslib": "^2.4.0"
Expand Down
14 changes: 12 additions & 2 deletions packages/builders/src/components/ActionRow.ts
Expand Up @@ -11,14 +11,24 @@ import { normalizeArray, type RestOrArray } from '../util/normalizeArray.js';
import { ComponentBuilder } from './Component.js';
import { createComponentBuilder } from './Components.js';
import type { ButtonBuilder } from './button/Button.js';
import type { SelectMenuBuilder } from './selectMenu/SelectMenu.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';

export type MessageComponentBuilder =
| ActionRowBuilder<MessageActionRowComponentBuilder>
| MessageActionRowComponentBuilder;
export type ModalComponentBuilder = ActionRowBuilder<ModalActionRowComponentBuilder> | ModalActionRowComponentBuilder;
export type MessageActionRowComponentBuilder = ButtonBuilder | SelectMenuBuilder;
export type MessageActionRowComponentBuilder =
| ButtonBuilder
| ChannelSelectMenuBuilder
| MentionableSelectMenuBuilder
| RoleSelectMenuBuilder
| StringSelectMenuBuilder
| UserSelectMenuBuilder;
export type ModalActionRowComponentBuilder = TextInputBuilder;
export type AnyComponentBuilder = MessageActionRowComponentBuilder | ModalActionRowComponentBuilder;

Expand Down
10 changes: 6 additions & 4 deletions packages/builders/src/components/Assertions.ts
@@ -1,7 +1,7 @@
import { s } from '@sapphire/shapeshift';
import { ButtonStyle, type APIMessageComponentEmoji } from 'discord-api-types/v10';
import { ButtonStyle, ChannelType, type APIMessageComponentEmoji } from 'discord-api-types/v10';
import { isValidationEnabled } from '../util/validation.js';
import { SelectMenuOptionBuilder } from './selectMenu/SelectMenuOption.js';
import { StringSelectMenuOptionBuilder } from './selectMenu/StringSelectMenuOption.js';

export const customIdValidator = s.string
.lengthGreaterThanOrEqual(1)
Expand Down Expand Up @@ -46,7 +46,7 @@ export const jsonOptionValidator = s
})
.setValidationEnabled(isValidationEnabled);

export const optionValidator = s.instance(SelectMenuOptionBuilder).setValidationEnabled(isValidationEnabled);
export const optionValidator = s.instance(StringSelectMenuOptionBuilder).setValidationEnabled(isValidationEnabled);

export const optionsValidator = optionValidator.array
.lengthGreaterThanOrEqual(0)
Expand All @@ -56,7 +56,7 @@ export const optionsLengthValidator = s.number.int
.lessThanOrEqual(25)
.setValidationEnabled(isValidationEnabled);

export function validateRequiredSelectMenuParameters(options: SelectMenuOptionBuilder[], customId?: string) {
export function validateRequiredSelectMenuParameters(options: StringSelectMenuOptionBuilder[], customId?: string) {
customIdValidator.parse(customId);
optionsValidator.parse(options);
}
Expand All @@ -68,6 +68,8 @@ export function validateRequiredSelectMenuOptionParameters(label?: string, value
labelValueDescriptionValidator.parse(value);
}

export const channelTypesValidator = s.nativeEnum(ChannelType).array.setValidationEnabled(isValidationEnabled);

export const urlValidator = s.string
.url({
allowedProtocols: ['http:', 'https:', 'discord:'],
Expand Down
24 changes: 20 additions & 4 deletions packages/builders/src/components/Components.ts
Expand Up @@ -7,14 +7,22 @@ import {
} from './ActionRow.js';
import { ComponentBuilder } from './Component.js';
import { ButtonBuilder } from './button/Button.js';
import { SelectMenuBuilder } from './selectMenu/SelectMenu.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';

export interface MappedComponentTypes {
[ComponentType.ActionRow]: ActionRowBuilder<AnyComponentBuilder>;
[ComponentType.Button]: ButtonBuilder;
[ComponentType.SelectMenu]: SelectMenuBuilder;
[ComponentType.StringSelect]: StringSelectMenuBuilder;
[ComponentType.TextInput]: TextInputBuilder;
[ComponentType.UserSelect]: UserSelectMenuBuilder;
[ComponentType.RoleSelect]: RoleSelectMenuBuilder;
[ComponentType.MentionableSelect]: MentionableSelectMenuBuilder;
[ComponentType.ChannelSelect]: ChannelSelectMenuBuilder;
}

/**
Expand All @@ -39,10 +47,18 @@ export function createComponentBuilder(
return new ActionRowBuilder(data);
case ComponentType.Button:
return new ButtonBuilder(data);
case ComponentType.SelectMenu:
return new SelectMenuBuilder(data);
case ComponentType.StringSelect:
return new StringSelectMenuBuilder(data);
case ComponentType.TextInput:
return new TextInputBuilder(data);
case ComponentType.UserSelect:
return new UserSelectMenuBuilder(data);
case ComponentType.RoleSelect:
return new RoleSelectMenuBuilder(data);
case ComponentType.MentionableSelect:
return new MentionableSelectMenuBuilder(data);
case ComponentType.ChannelSelect:
return new ChannelSelectMenuBuilder(data);
default:
// @ts-expect-error: This case can still occur if we get a newer unsupported component type
throw new Error(`Cannot properly serialize component type: ${data.type}`);
Expand Down
64 changes: 64 additions & 0 deletions packages/builders/src/components/selectMenu/BaseSelectMenu.ts
@@ -0,0 +1,64 @@
import type { APISelectMenuComponent } from 'discord-api-types/v10';
import { customIdValidator, disabledValidator, minMaxValidator, placeholderValidator } from '../Assertions.js';
import { ComponentBuilder } from '../Component.js';

export class BaseSelectMenuBuilder<
SelectMenuType extends APISelectMenuComponent,
> extends ComponentBuilder<SelectMenuType> {
/**
* Sets the placeholder for this select menu
*
* @param placeholder - The placeholder to use for this select menu
*/
public setPlaceholder(placeholder: string) {
this.data.placeholder = placeholderValidator.parse(placeholder);
return this;
}

/**
* Sets the minimum values that must be selected in the select menu
*
* @param minValues - The minimum values that must be selected
*/
public setMinValues(minValues: number) {
this.data.min_values = minMaxValidator.parse(minValues);
return this;
}

/**
* Sets the maximum values that must be selected in the select menu
*
* @param maxValues - The maximum values that must be selected
*/
public setMaxValues(maxValues: number) {
this.data.max_values = minMaxValidator.parse(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) {
this.data.custom_id = customIdValidator.parse(customId);
return this;
}

/**
* Sets whether this select menu is disabled
*
* @param disabled - Whether this select menu is disabled
*/
public setDisabled(disabled = true) {
this.data.disabled = disabledValidator.parse(disabled);
return this;
}

public toJSON(): SelectMenuType {
customIdValidator.parse(this.data.custom_id);
return {
...this.data,
} as SelectMenuType;
}
}
63 changes: 63 additions & 0 deletions packages/builders/src/components/selectMenu/ChannelSelectMenu.ts
@@ -0,0 +1,63 @@
import type { APIChannelSelectComponent, ChannelType } from 'discord-api-types/v10';
import { ComponentType } from 'discord-api-types/v10';
import { normalizeArray, type RestOrArray } from '../../util/normalizeArray.js';
import { channelTypesValidator, customIdValidator } from '../Assertions.js';
import { BaseSelectMenuBuilder } from './BaseSelectMenu.js';

export class ChannelSelectMenuBuilder extends BaseSelectMenuBuilder<APIChannelSelectComponent> {
/**
* Creates a new select menu from API data
*
* @param data - The API data to create this select menu with
* @example
* Creating a select menu from an API data object
* ```ts
* const selectMenu = new ChannelSelectMenuBuilder({
* custom_id: 'a cool select menu',
* placeholder: 'select an option',
* max_values: 2,
* });
* ```
* @example
* Creating a select menu using setters and API data
* ```ts
* const selectMenu = new ChannelSelectMenuBuilder({
* custom_id: 'a cool select menu',
* })
* .addChannelTypes(ChannelType.GuildText, ChannelType.GuildAnnouncement)
* .setMinValues(2)
* ```
*/
public constructor(data?: Partial<APIChannelSelectComponent>) {
super({ ...data, type: ComponentType.ChannelSelect });
}

public addChannelTypes(...types: RestOrArray<ChannelType>) {
// eslint-disable-next-line no-param-reassign
types = normalizeArray(types);

this.data.channel_types ??= [];
this.data.channel_types.push(...channelTypesValidator.parse(types));
return this;
}

public setChannelTypes(...types: RestOrArray<ChannelType>) {
// eslint-disable-next-line no-param-reassign
types = normalizeArray(types);

this.data.channel_types ??= [];
this.data.channel_types.splice(0, this.data.channel_types.length, ...channelTypesValidator.parse(types));
return this;
}

/**
* {@inheritDoc ComponentBuilder.toJSON}
*/
public override toJSON(): APIChannelSelectComponent {
customIdValidator.parse(this.data.custom_id);

return {
...this.data,
} as APIChannelSelectComponent;
}
}

0 comments on commit 5152abf

Please sign in to comment.