refactor: Don't return builders from API data (#7584)

* refactor: don't return builders from API data

* Update packages/discord.js/src/structures/ActionRow.js

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

* fix: circular dependency

* fix: circular dependency pt.2

* chore: make requested changes

* chore: bump dapi-types

* chore: convert text input

* chore: convert text input

* feat: handle cases of unknown component types better

* refactor: refactor modal to builder

* feat: add #from for easy builder conversions

* refactor: make requested changes

* chore: make requested changes

* style: fix linting error

Co-authored-by: Antonio Román <kyradiscord@gmail.com>
Co-authored-by: almeidx <almeidx@pm.me>
This commit is contained in:
Suneet Tipirneni
2022-03-12 13:39:23 -05:00
committed by GitHub
parent 230c0c4cb1
commit 549716e4fc
44 changed files with 974 additions and 705 deletions

View File

@@ -1,26 +1,29 @@
import {
APIActionRowComponent,
type APIActionRowComponent,
ComponentType,
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';
import type { ButtonBuilder, SelectMenuBuilder, TextInputBuilder } from '..';
import { ComponentBuilder } from './Component';
import { createComponentBuilder } from './Components';
export type MessageComponent = MessageActionRowComponent | ActionRow<MessageActionRowComponent>;
export type ModalComponent = ModalActionRowComponent | ActionRow<ModalActionRowComponent>;
export type MessageComponentBuilder =
| MessageActionRowComponentBuilder
| ActionRowBuilder<MessageActionRowComponentBuilder>;
export type ModalComponentBuilder = ModalActionRowComponentBuilder | ActionRowBuilder<ModalActionRowComponentBuilder>;
export type MessageActionRowComponent = ButtonComponent | SelectMenuComponent;
export type ModalActionRowComponent = TextInputComponent;
export type MessageActionRowComponentBuilder = ButtonBuilder | SelectMenuBuilder;
export type ModalActionRowComponentBuilder = TextInputBuilder;
/**
* Represents an action row component
*/
export class ActionRow<
T extends ModalActionRowComponent | MessageActionRowComponent = ModalActionRowComponent | MessageActionRowComponent,
> extends Component<
export class ActionRowBuilder<
T extends MessageActionRowComponentBuilder | ModalActionRowComponentBuilder =
| MessageActionRowComponentBuilder
| ModalActionRowComponentBuilder,
> extends ComponentBuilder<
Omit<
Partial<APIActionRowComponent<APIMessageActionRowComponent | APIModalActionRowComponent>> & {
type: ComponentType.ActionRow;
@@ -31,14 +34,14 @@ export class ActionRow<
/**
* The components within this action row
*/
public readonly components: T[];
private readonly components: T[];
public constructor({
components,
...data
}: Partial<APIActionRowComponent<APIMessageActionRowComponent | APIModalActionRowComponent>> = {}) {
super({ type: ComponentType.ActionRow, ...data });
this.components = (components?.map((c) => createComponent(c)) ?? []) as T[];
this.components = (components?.map((c) => createComponentBuilder(c)) ?? []) as T[];
}
/**
@@ -66,14 +69,4 @@ export class ActionRow<
components: this.components.map((component) => component.toJSON()) as ReturnType<T['toJSON']>[],
};
}
public equals(other: APIActionRowComponent<APIMessageActionRowComponent | APIModalActionRowComponent> | ActionRow) {
if (other instanceof ActionRow) {
return isEqual(other.data, this.data) && isEqual(other.components, this.components);
}
return isEqual(other, {
...this.data,
components: this.components.map((component) => component.toJSON()),
});
}
}

View File

@@ -1,6 +1,6 @@
import { APIMessageComponentEmoji, ButtonStyle } from 'discord-api-types/v9';
import { z } from 'zod';
import type { SelectMenuOption } from './selectMenu/SelectMenuOption';
import type { SelectMenuOptionBuilder } from './selectMenu/SelectMenuOption';
export const customIdValidator = z.string().min(1).max(100);
@@ -24,7 +24,7 @@ export const minMaxValidator = z.number().int().min(0).max(25);
export const optionsValidator = z.object({}).array().nonempty();
export function validateRequiredSelectMenuParameters(options: SelectMenuOption[], customId?: string) {
export function validateRequiredSelectMenuParameters(options: SelectMenuOptionBuilder[], customId?: string) {
customIdValidator.parse(customId);
optionsValidator.parse(options);
}

View File

@@ -4,17 +4,16 @@ import type {
APIActionRowComponentTypes,
APIBaseComponent,
APIMessageActionRowComponent,
APIModalActionRowComponent,
APIMessageComponent,
ComponentType,
APIModalActionRowComponent,
APIModalComponent,
ComponentType,
} from 'discord-api-types/v9';
import type { Equatable } from '../util/equatable';
/**
* Represents a discord component
*/
export abstract class Component<
export abstract class ComponentBuilder<
DataType extends Partial<APIBaseComponent<ComponentType>> & {
type: ComponentType;
} = APIBaseComponent<ComponentType>,
@@ -23,11 +22,6 @@ export abstract class Component<
| APIModalComponent
| APIMessageComponent
| APIActionRowComponent<APIModalActionRowComponent | APIMessageActionRowComponent>
>,
Equatable<
| Component
| APIActionRowComponentTypes
| APIActionRowComponent<APIModalActionRowComponent | APIMessageActionRowComponent>
>
{
/**
@@ -39,21 +33,7 @@ export abstract class Component<
| APIActionRowComponentTypes
| APIActionRowComponent<APIModalActionRowComponent | APIMessageActionRowComponent>;
public abstract equals(
other:
| Component
| APIActionRowComponentTypes
| APIActionRowComponent<APIModalActionRowComponent | APIMessageActionRowComponent>,
): boolean;
public constructor(data: DataType) {
this.data = data;
}
/**
* The type of this component
*/
public get type(): DataType['type'] {
return this.data.type;
}
}

View File

@@ -1,37 +1,41 @@
import { APIBaseComponent, APIMessageComponent, APIModalComponent, ComponentType } from 'discord-api-types/v9';
import { ActionRow, ButtonComponent, Component, SelectMenuComponent, TextInputComponent } from '../index';
import type { MessageComponent, ModalActionRowComponent } from './ActionRow';
import { APIMessageComponent, APIModalComponent, ComponentType } from 'discord-api-types/v9';
import { ActionRowBuilder, ButtonBuilder, ComponentBuilder, SelectMenuBuilder, TextInputBuilder } from '../index';
import type { MessageComponentBuilder, ModalComponentBuilder } from './ActionRow';
export interface MappedComponentTypes {
[ComponentType.ActionRow]: ActionRow;
[ComponentType.Button]: ButtonComponent;
[ComponentType.SelectMenu]: SelectMenuComponent;
[ComponentType.TextInput]: TextInputComponent;
[ComponentType.ActionRow]: ActionRowBuilder;
[ComponentType.Button]: ButtonBuilder;
[ComponentType.SelectMenu]: SelectMenuBuilder;
[ComponentType.TextInput]: TextInputBuilder;
}
/**
* 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>(
export function createComponentBuilder<T extends keyof MappedComponentTypes>(
data: (APIMessageComponent | APIModalComponent) & { type: T },
): MappedComponentTypes[T];
export function createComponent<C extends MessageComponent | ModalActionRowComponent>(data: C): C;
export function createComponent(data: APIModalComponent | APIMessageComponent | Component): Component {
if (data instanceof Component) {
export function createComponentBuilder<C extends MessageComponentBuilder | ModalComponentBuilder>(data: C): C;
export function createComponentBuilder(
data: APIMessageComponent | APIModalComponent | MessageComponentBuilder,
): ComponentBuilder {
if (data instanceof ComponentBuilder) {
return data;
}
switch (data.type) {
case ComponentType.ActionRow:
return new ActionRow(data);
return new ActionRowBuilder(data);
case ComponentType.Button:
return new ButtonComponent(data);
return new ButtonBuilder(data);
case ComponentType.SelectMenu:
return new SelectMenuComponent(data);
return new SelectMenuBuilder(data);
case ComponentType.TextInput:
return new TextInputComponent(data);
return new TextInputBuilder(data);
default:
throw new Error(`Cannot serialize component type: ${(data as APIBaseComponent<ComponentType>).type}`);
// @ts-expect-error
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
throw new Error(`Cannot properly serialize component type: ${data.type}`);
}
}

View File

@@ -1,4 +1,10 @@
import type { ButtonStyle, APIMessageComponentEmoji, APIButtonComponent } from 'discord-api-types/v9';
import type {
ButtonStyle,
APIMessageComponentEmoji,
APIButtonComponent,
APIButtonComponentWithCustomId,
APIButtonComponentWithURL,
} from 'discord-api-types/v9';
import {
buttonLabelValidator,
buttonStyleValidator,
@@ -8,12 +14,12 @@ import {
urlValidator,
validateRequiredButtonParameters,
} from '../Assertions';
import { UnsafeButtonComponent } from './UnsafeButton';
import { UnsafeButtonBuilder } from './UnsafeButton';
/**
* Represents a validated button component
*/
export class ButtonComponent extends UnsafeButtonComponent {
export class ButtonBuilder extends UnsafeButtonBuilder {
public override setStyle(style: ButtonStyle) {
return super.setStyle(buttonStyleValidator.parse(style));
}
@@ -39,7 +45,13 @@ export class ButtonComponent extends UnsafeButtonComponent {
}
public override toJSON(): APIButtonComponent {
validateRequiredButtonParameters(this.style, this.label, this.emoji, this.customId, this.url);
validateRequiredButtonParameters(
this.data.style,
this.data.label,
this.data.emoji,
(this.data as APIButtonComponentWithCustomId).custom_id,
(this.data as APIButtonComponentWithURL).url,
);
return super.toJSON();
}
}

View File

@@ -6,59 +6,18 @@ import {
type APIButtonComponentWithURL,
type APIButtonComponentWithCustomId,
} from 'discord-api-types/v9';
import { Component } from '../Component';
import isEqual from 'fast-deep-equal';
import { ComponentBuilder } from '../Component';
/**
* Represents a non-validated button component
*/
export class UnsafeButtonComponent extends Component<Partial<APIButtonComponent> & { type: ComponentType.Button }> {
export class UnsafeButtonBuilder extends ComponentBuilder<
Partial<APIButtonComponent> & { type: ComponentType.Button }
> {
public constructor(data?: Partial<APIButtonComponent>) {
super({ type: ComponentType.Button, ...data });
}
/**
* The style of this button
*/
public get style() {
return this.data.style;
}
/**
* The label of this button
*/
public get label() {
return this.data.label;
}
/**
* The emoji used in this button
*/
public get emoji() {
return this.data.emoji;
}
/**
* Whether this button is disabled
*/
public get disabled() {
return this.data.disabled;
}
/**
* The custom id of this button (only defined on non-link buttons)
*/
public get customId(): string | undefined {
return (this.data as APIButtonComponentWithCustomId).custom_id;
}
/**
* The URL of this button (only defined on link buttons)
*/
public get url(): string | undefined {
return (this.data as APIButtonComponentWithURL).url;
}
/**
* Sets the style of this button
* @param style The style of the button
@@ -119,11 +78,4 @@ export class UnsafeButtonComponent extends Component<Partial<APIButtonComponent>
...this.data,
} as APIButtonComponent;
}
public equals(other: APIButtonComponent | UnsafeButtonComponent) {
if (other instanceof UnsafeButtonComponent) {
return isEqual(other.data, this.data);
}
return isEqual(other, this.data);
}
}

View File

@@ -6,12 +6,12 @@ import {
placeholderValidator,
validateRequiredSelectMenuParameters,
} from '../Assertions';
import { UnsafeSelectMenuComponent } from './UnsafeSelectMenu';
import { UnsafeSelectMenuBuilder } from './UnsafeSelectMenu';
/**
* Represents a validated select menu component
*/
export class SelectMenuComponent extends UnsafeSelectMenuComponent {
export class SelectMenuBuilder extends UnsafeSelectMenuBuilder {
public override setPlaceholder(placeholder: string) {
return super.setPlaceholder(placeholderValidator.parse(placeholder));
}
@@ -33,7 +33,7 @@ export class SelectMenuComponent extends UnsafeSelectMenuComponent {
}
public override toJSON(): APISelectMenuComponent {
validateRequiredSelectMenuParameters(this.options, this.customId);
validateRequiredSelectMenuParameters(this.options, this.data.custom_id);
return super.toJSON();
}
}

View File

@@ -5,12 +5,12 @@ import {
labelValueValidator,
validateRequiredSelectMenuOptionParameters,
} from '../Assertions';
import { UnsafeSelectMenuOption } from './UnsafeSelectMenuOption';
import { UnsafeSelectMenuOptionBuilder } from './UnsafeSelectMenuOption';
/**
* Represents a validated option within a select menu component
*/
export class SelectMenuOption extends UnsafeSelectMenuOption {
export class SelectMenuOptionBuilder extends UnsafeSelectMenuOptionBuilder {
public override setDescription(description: string) {
return super.setDescription(labelValueValidator.parse(description));
}
@@ -24,7 +24,7 @@ export class SelectMenuOption extends UnsafeSelectMenuOption {
}
public override toJSON(): APISelectMenuOption {
validateRequiredSelectMenuOptionParameters(this.label, this.value);
validateRequiredSelectMenuOptionParameters(this.data.label, this.data.value);
return super.toJSON();
}
}

View File

@@ -1,58 +1,22 @@
import { APISelectMenuOption, ComponentType, type APISelectMenuComponent } from 'discord-api-types/v9';
import { Component } from '../Component';
import { UnsafeSelectMenuOption } from './UnsafeSelectMenuOption';
import isEqual from 'fast-deep-equal';
import { ComponentBuilder } from '../Component';
import { UnsafeSelectMenuOptionBuilder } from './UnsafeSelectMenuOption';
/**
* Represents a non-validated select menu component
*/
export class UnsafeSelectMenuComponent extends Component<
export class UnsafeSelectMenuBuilder extends ComponentBuilder<
Partial<Omit<APISelectMenuComponent, 'options'>> & { type: ComponentType.SelectMenu }
> {
/**
* The options within this select menu
*/
public readonly options: UnsafeSelectMenuOption[];
protected readonly options: UnsafeSelectMenuOptionBuilder[];
public constructor(data?: Partial<APISelectMenuComponent>) {
const { options, ...initData } = data ?? {};
super({ type: ComponentType.SelectMenu, ...initData });
this.options = options?.map((o) => new UnsafeSelectMenuOption(o)) ?? [];
}
/**
* The placeholder for this select menu
*/
public get placeholder() {
return this.data.placeholder;
}
/**
* The maximum amount of options that can be selected
*/
public get maxValues() {
return this.data.max_values;
}
/**
* The minimum amount of options that must be selected
*/
public get minValues() {
return this.data.min_values;
}
/**
* The custom id of this select menu
*/
public get customId() {
return this.data.custom_id;
}
/**
* Whether this select menu is disabled
*/
public get disabled() {
return this.data.disabled;
this.options = options?.map((o) => new UnsafeSelectMenuOptionBuilder(o)) ?? [];
}
/**
@@ -105,10 +69,10 @@ export class UnsafeSelectMenuComponent extends Component<
* @param options The options to add to this select menu
* @returns
*/
public addOptions(...options: (UnsafeSelectMenuOption | APISelectMenuOption)[]) {
public addOptions(...options: (UnsafeSelectMenuOptionBuilder | APISelectMenuOption)[]) {
this.options.push(
...options.map((option) =>
option instanceof UnsafeSelectMenuOption ? option : new UnsafeSelectMenuOption(option),
option instanceof UnsafeSelectMenuOptionBuilder ? option : new UnsafeSelectMenuOptionBuilder(option),
),
);
return this;
@@ -118,12 +82,12 @@ export class UnsafeSelectMenuComponent extends Component<
* Sets the options on this select menu
* @param options The options to set on this select menu
*/
public setOptions(...options: (UnsafeSelectMenuOption | APISelectMenuOption)[]) {
public setOptions(...options: (UnsafeSelectMenuOptionBuilder | APISelectMenuOption)[]) {
this.options.splice(
0,
this.options.length,
...options.map((option) =>
option instanceof UnsafeSelectMenuOption ? option : new UnsafeSelectMenuOption(option),
option instanceof UnsafeSelectMenuOptionBuilder ? option : new UnsafeSelectMenuOptionBuilder(option),
),
);
return this;
@@ -136,14 +100,4 @@ export class UnsafeSelectMenuComponent extends Component<
options: this.options.map((o) => o.toJSON()),
} as APISelectMenuComponent;
}
public equals(other: APISelectMenuComponent | UnsafeSelectMenuComponent): boolean {
if (other instanceof UnsafeSelectMenuComponent) {
return isEqual(other.data, this.data) && isEqual(other.options, this.options);
}
return isEqual(other, {
...this.data,
options: this.options.map((o) => o.toJSON()),
});
}
}

View File

@@ -3,43 +3,8 @@ import type { APIMessageComponentEmoji, APISelectMenuOption } from 'discord-api-
/**
* Represents a non-validated option within a select menu component
*/
export class UnsafeSelectMenuOption {
public constructor(protected data: Partial<APISelectMenuOption> = {}) {}
/**
* The label for this option
*/
public get label() {
return this.data.label;
}
/**
* The value for this option
*/
public get value() {
return this.data.value;
}
/**
* The description for this option
*/
public get description() {
return this.data.description;
}
/**
* The emoji for this option
*/
public get emoji() {
return this.data.emoji;
}
/**
* Whether this option is selected by default
*/
public get default() {
return this.data.default;
}
export class UnsafeSelectMenuOptionBuilder {
public constructor(public data: Partial<APISelectMenuOption> = {}) {}
/**
* Sets the label of this option

View File

@@ -7,9 +7,9 @@ import {
valueValidator,
validateRequiredParameters,
} from './Assertions';
import { UnsafeTextInputComponent } from './UnsafeTextInput';
import { UnsafeTextInputBuilder } from './UnsafeTextInput';
export class TextInputComponent extends UnsafeTextInputComponent {
export class TextInputBuilder extends UnsafeTextInputBuilder {
public override setMinLength(minLength: number) {
return super.setMinLength(minLengthValidator.parse(minLength));
}

View File

@@ -1,73 +1,17 @@
import { ComponentType, type TextInputStyle, type APITextInputComponent } from 'discord-api-types/v9';
import { Component } from '../../index';
import { ComponentBuilder } from '../../index';
import isEqual from 'fast-deep-equal';
export class UnsafeTextInputComponent extends Component<
export class UnsafeTextInputBuilder extends ComponentBuilder<
Partial<APITextInputComponent> & { type: ComponentType.TextInput }
> {
public constructor(data?: APITextInputComponent & { type?: ComponentType.TextInput }) {
super({ type: ComponentType.TextInput, ...data });
}
/**
* The style of this text input
*/
public get style() {
return this.data.style;
}
/**
* The custom id of this text input
*/
public get customId() {
return this.data.custom_id;
}
/**
* The label for this text input
*/
public get label() {
return this.data.label;
}
/**
* The placeholder text for this text input
*/
public get placeholder() {
return this.data.placeholder;
}
/**
* The default value for this text input
*/
public get value() {
return this.data.value;
}
/**
* The minimum length of this text input
*/
public get minLength() {
return this.data.min_length;
}
/**
* The maximum length of this text input
*/
public get maxLength() {
return this.data.max_length;
}
/**
* Whether this text input is required
*/
public get required() {
return this.data.required;
}
/**
* Sets the custom id for this text input
* @param customId The custom id of this text input
* @param customId The custom id of this text inputå
*/
public setCustomId(customId: string) {
this.data.custom_id = customId;
@@ -144,8 +88,8 @@ export class UnsafeTextInputComponent extends Component<
} as APITextInputComponent;
}
public equals(other: UnsafeTextInputComponent | APITextInputComponent): boolean {
if (other instanceof UnsafeTextInputComponent) {
public equals(other: UnsafeTextInputBuilder | APITextInputComponent): boolean {
if (other instanceof UnsafeTextInputBuilder) {
return isEqual(other.data, this.data);
}