From f7257f07655076eabfe355cb6a53260b39ca9670 Mon Sep 17 00:00:00 2001 From: Suneet Tipirneni <77477100+suneettipirneni@users.noreply.github.com> Date: Thu, 17 Feb 2022 19:04:34 -0500 Subject: [PATCH] feat: add missing `v13` component methods (#7466) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Vlad Frangu Co-authored-by: Rodry <38259440+ImRodry@users.noreply.github.com> Co-authored-by: Antonio Román --- .../__tests__/components/actionRow.test.ts | 72 ++++++++++--------- .../__tests__/components/button.test.ts | 12 ++++ .../__tests__/components/selectMenu.test.ts | 52 ++++++++------ .../builders/__tests__/messages/embed.test.ts | 9 +-- packages/builders/package.json | 1 + packages/builders/src/components/ActionRow.ts | 11 +++ packages/builders/src/components/Component.ts | 15 ++-- .../src/components/button/UnsafeButton.ts | 8 +++ .../components/selectMenu/UnsafeSelectMenu.ts | 31 ++++++-- .../builders/src/messages/embed/Assertions.ts | 9 ++- packages/builders/src/messages/embed/Embed.ts | 4 +- .../src/messages/embed/UnsafeEmbed.ts | 27 ++++++- packages/builders/src/util/equatable.ts | 14 ++++ yarn.lock | 1 + 14 files changed, 190 insertions(+), 76 deletions(-) create mode 100644 packages/builders/src/util/equatable.ts diff --git a/packages/builders/__tests__/components/actionRow.test.ts b/packages/builders/__tests__/components/actionRow.test.ts index 51cc4f2064a9..cd651c745349 100644 --- a/packages/builders/__tests__/components/actionRow.test.ts +++ b/packages/builders/__tests__/components/actionRow.test.ts @@ -1,6 +1,40 @@ import { APIActionRowComponent, APIMessageComponent, ButtonStyle, ComponentType } from 'discord-api-types/v9'; import { ActionRow, ButtonComponent, createComponent, SelectMenuComponent, SelectMenuOption } from '../../src'; +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, + }, + ], +}; + describe('Action Row Components', () => { describe('Assertion Tests', () => { test('GIVEN valid components THEN do not throw', () => { @@ -45,40 +79,6 @@ describe('Action Row Components', () => { 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') @@ -92,5 +92,9 @@ describe('Action Row Components', () => { expect(new ActionRow().addComponents(button).toJSON()).toEqual(rowWithButtonData); expect(new ActionRow().addComponents(selectMenu).toJSON()).toEqual(rowWithSelectMenuData); }); + test('Given JSON data THEN builder is equal to it and itself', () => { + expect(new ActionRow(rowWithSelectMenuData).equals(rowWithSelectMenuData)).toBeTruthy(); + expect(new ActionRow(rowWithButtonData).equals(new ActionRow(rowWithButtonData))).toBeTruthy(); + }); }); }); diff --git a/packages/builders/__tests__/components/button.test.ts b/packages/builders/__tests__/components/button.test.ts index 47f829bfcbc6..0193f1519e68 100644 --- a/packages/builders/__tests__/components/button.test.ts +++ b/packages/builders/__tests__/components/button.test.ts @@ -142,5 +142,17 @@ describe('Button Components', () => { expect(buttonComponent().setLabel(linkData.label).setDisabled(true).setURL(linkData.url)); }); + test('Given JSON data THEN builder is equal to it and itself', () => { + const buttonData: APIButtonComponentWithCustomId = { + type: ComponentType.Button, + custom_id: 'test', + label: 'test', + style: ButtonStyle.Primary, + disabled: true, + }; + + expect(new ButtonComponent(buttonData).equals(buttonData)).toBeTruthy(); + expect(new ButtonComponent(buttonData).equals(new ButtonComponent(buttonData))).toBeTruthy(); + }); }); }); diff --git a/packages/builders/__tests__/components/selectMenu.test.ts b/packages/builders/__tests__/components/selectMenu.test.ts index 6b4692df591c..da4fe1b2874f 100644 --- a/packages/builders/__tests__/components/selectMenu.test.ts +++ b/packages/builders/__tests__/components/selectMenu.test.ts @@ -7,7 +7,29 @@ const selectMenuOption = () => new SelectMenuOption(); const longStr = 'looooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong'; -describe('Button Components', () => { +const selectMenuOptionData: APISelectMenuOption = { + label: 'test', + value: 'test', + emoji: { name: 'test' }, + default: true, + description: 'test', +}; + +const selectMenuDataWithoutOptions = { + type: ComponentType.SelectMenu, + custom_id: 'test', + max_values: 10, + min_values: 3, + disabled: true, + placeholder: 'test', +} as const; + +const selectMenuData: APISelectMenuComponent = { + ...selectMenuDataWithoutOptions, + options: [selectMenuOptionData], +}; + +describe('Select Menu Components', () => { describe('Assertion Tests', () => { test('GIVEN valid inputs THEN Select Menu does not throw', () => { expect(() => selectMenu().setCustomId('foo')).not.toThrowError(); @@ -24,6 +46,7 @@ describe('Button Components', () => { .setDescription('description'); expect(() => selectMenu().addOptions(option)).not.toThrowError(); expect(() => selectMenu().setOptions([option])).not.toThrowError(); + expect(() => selectMenu().setOptions([{ label: 'test', value: 'test' }])).not.toThrowError(); }); test('GIVEN invalid inputs THEN Select Menu does throw', () => { @@ -47,28 +70,6 @@ describe('Button Components', () => { }); 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 selectMenuDataWithoutOptions = { - type: ComponentType.SelectMenu, - custom_id: 'test', - max_values: 10, - min_values: 3, - disabled: true, - placeholder: 'test', - } as const; - - const selectMenuData: APISelectMenuComponent = { - ...selectMenuDataWithoutOptions, - options: [selectMenuOptionData], - }; - expect( new SelectMenuComponent(selectMenuDataWithoutOptions) .addOptions(new SelectMenuOption(selectMenuOptionData)) @@ -76,5 +77,10 @@ describe('Button Components', () => { ).toEqual(selectMenuData); expect(new SelectMenuOption(selectMenuOptionData).toJSON()).toEqual(selectMenuOptionData); }); + + test('Given JSON data THEN builder is equal to it and itself', () => { + expect(new SelectMenuComponent(selectMenuData).equals(selectMenuData)).toBeTruthy(); + expect(new SelectMenuComponent(selectMenuData).equals(new SelectMenuComponent(selectMenuData))).toBeTruthy(); + }); }); }); diff --git a/packages/builders/__tests__/messages/embed.test.ts b/packages/builders/__tests__/messages/embed.test.ts index 10520efb01bf..00984a89d329 100644 --- a/packages/builders/__tests__/messages/embed.test.ts +++ b/packages/builders/__tests__/messages/embed.test.ts @@ -115,10 +115,8 @@ describe('Embed', () => { }); test('GIVEN an embed using Embed#setColor THEN returns valid toJSON data', () => { - const embed = new Embed(); - embed.setColor(0xff0000); - - expect(embed.toJSON()).toStrictEqual({ color: 0xff0000 }); + expect(new Embed().setColor(0xff0000).toJSON()).toStrictEqual({ color: 0xff0000 }); + expect(new Embed().setColor([242, 66, 245]).toJSON()).toStrictEqual({ color: 0xf242f5 }); }); test('GIVEN an embed with a pre-defined color THEN unset color THEN return valid toJSON data', () => { @@ -133,6 +131,9 @@ describe('Embed', () => { // @ts-expect-error expect(() => embed.setColor('RED')).toThrowError(); + // @ts-expect-error + expect(() => embed.setColor([42, 36])).toThrowError(); + expect(() => embed.setColor([42, 36, 1000])).toThrowError(); }); }); diff --git a/packages/builders/package.json b/packages/builders/package.json index 6ed371918401..2bd79bf33374 100644 --- a/packages/builders/package.json +++ b/packages/builders/package.json @@ -53,6 +53,7 @@ "dependencies": { "@sindresorhus/is": "^4.4.0", "discord-api-types": "^0.27.0", + "fast-deep-equal": "^3.1.3", "ts-mixer": "^6.0.0", "tslib": "^2.3.1", "zod": "^3.11.6" diff --git a/packages/builders/src/components/ActionRow.ts b/packages/builders/src/components/ActionRow.ts index 31230c940db3..070461e56cb9 100644 --- a/packages/builders/src/components/ActionRow.ts +++ b/packages/builders/src/components/ActionRow.ts @@ -2,6 +2,7 @@ import { type APIActionRowComponent, ComponentType, APIMessageComponent } from ' import type { ButtonComponent, SelectMenuComponent } from '..'; import { Component } from './Component'; import { createComponent } from './Components'; +import isEqual from 'fast-deep-equal'; export type MessageComponent = ActionRowComponent | ActionRow; @@ -46,4 +47,14 @@ export class ActionRow extend components: this.components.map((component) => component.toJSON()), }; } + + public equals(other: APIActionRowComponent | 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()), + }); + } } diff --git a/packages/builders/src/components/Component.ts b/packages/builders/src/components/Component.ts index b3a93ba68f33..b759b5cb2958 100644 --- a/packages/builders/src/components/Component.ts +++ b/packages/builders/src/components/Component.ts @@ -1,5 +1,11 @@ import type { JSONEncodable } from '../util/jsonEncodable'; -import type { APIBaseComponent, APIMessageComponent, ComponentType } from 'discord-api-types/v9'; +import type { + APIActionRowComponentTypes, + APIBaseComponent, + APIMessageComponent, + ComponentType, +} from 'discord-api-types/v9'; +import type { Equatable } from '../util/equatable'; /** * Represents a discord component @@ -8,18 +14,17 @@ export abstract class Component< DataType extends Partial> & { type: ComponentType; } = APIBaseComponent, -> implements JSONEncodable +> implements JSONEncodable, Equatable { /** * The API data associated with this component */ protected readonly data: DataType; - /** - * Converts this component to an API-compatible JSON object - */ public abstract toJSON(): APIMessageComponent; + public abstract equals(other: Component | APIActionRowComponentTypes): boolean; + public constructor(data: DataType) { this.data = data; } diff --git a/packages/builders/src/components/button/UnsafeButton.ts b/packages/builders/src/components/button/UnsafeButton.ts index 149ed87d1cd5..0be03584583b 100644 --- a/packages/builders/src/components/button/UnsafeButton.ts +++ b/packages/builders/src/components/button/UnsafeButton.ts @@ -7,6 +7,7 @@ import { type APIButtonComponentWithCustomId, } from 'discord-api-types/v9'; import { Component } from '../Component'; +import isEqual from 'fast-deep-equal'; /** * Represents a non-validated button component @@ -118,4 +119,11 @@ export class UnsafeButtonComponent extends Component ...this.data, } as APIButtonComponent; } + + public equals(other: APIButtonComponent | UnsafeButtonComponent) { + if (other instanceof UnsafeButtonComponent) { + return isEqual(other.data, this.data); + } + return isEqual(other, this.data); + } } diff --git a/packages/builders/src/components/selectMenu/UnsafeSelectMenu.ts b/packages/builders/src/components/selectMenu/UnsafeSelectMenu.ts index 63720e09aa6f..0eedd854c7b0 100644 --- a/packages/builders/src/components/selectMenu/UnsafeSelectMenu.ts +++ b/packages/builders/src/components/selectMenu/UnsafeSelectMenu.ts @@ -1,6 +1,7 @@ -import { ComponentType, type APISelectMenuComponent } from 'discord-api-types/v9'; +import { APISelectMenuOption, ComponentType, type APISelectMenuComponent } from 'discord-api-types/v9'; import { Component } from '../Component'; import { UnsafeSelectMenuOption } from './UnsafeSelectMenuOption'; +import isEqual from 'fast-deep-equal'; /** * Represents a non-validated select menu component @@ -101,8 +102,12 @@ export class UnsafeSelectMenuComponent extends Component< * @param options The options to add to this select menu * @returns */ - public addOptions(...options: UnsafeSelectMenuOption[]) { - this.options.push(...options); + public addOptions(...options: (UnsafeSelectMenuOption | APISelectMenuOption)[]) { + this.options.push( + ...options.map((option) => + option instanceof UnsafeSelectMenuOption ? option : new UnsafeSelectMenuOption(option), + ), + ); return this; } @@ -110,8 +115,14 @@ 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[]) { - this.options.splice(0, this.options.length, ...options); + public setOptions(...options: (UnsafeSelectMenuOption | APISelectMenuOption)[]) { + this.options.splice( + 0, + this.options.length, + ...options.map((option) => + option instanceof UnsafeSelectMenuOption ? option : new UnsafeSelectMenuOption(option), + ), + ); return this; } @@ -122,4 +133,14 @@ 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()), + }); + } } diff --git a/packages/builders/src/messages/embed/Assertions.ts b/packages/builders/src/messages/embed/Assertions.ts index 8dade68f9fa0..b863c6ddf149 100644 --- a/packages/builders/src/messages/embed/Assertions.ts +++ b/packages/builders/src/messages/embed/Assertions.ts @@ -25,7 +25,14 @@ export const authorNamePredicate = fieldNamePredicate.nullable(); export const urlPredicate = z.string().url().nullish(); -export const colorPredicate = z.number().gte(0).lte(0xffffff).nullable(); +export const RGBPredicate = z.number().int().gte(0).lte(255); +export const colorPredicate = z + .number() + .int() + .gte(0) + .lte(0xffffff) + .nullable() + .or(z.tuple([RGBPredicate, RGBPredicate, RGBPredicate])); export const descriptionPredicate = z.string().min(1).max(4096).nullable(); diff --git a/packages/builders/src/messages/embed/Embed.ts b/packages/builders/src/messages/embed/Embed.ts index c167988d2723..ec3b4f0f9c5f 100644 --- a/packages/builders/src/messages/embed/Embed.ts +++ b/packages/builders/src/messages/embed/Embed.ts @@ -13,7 +13,7 @@ import { urlPredicate, validateFieldLength, } from './Assertions'; -import { EmbedAuthorOptions, EmbedFooterOptions, UnsafeEmbed } from './UnsafeEmbed'; +import { EmbedAuthorOptions, EmbedFooterOptions, RGBTuple, UnsafeEmbed } from './UnsafeEmbed'; /** * Represents a validated embed in a message (image/video preview, rich embed, etc.) @@ -48,7 +48,7 @@ export class Embed extends UnsafeEmbed { return super.setAuthor(options); } - public override setColor(color: number | null): this { + public override setColor(color: number | RGBTuple | null): this { // Data assertions return super.setColor(colorPredicate.parse(color)); } diff --git a/packages/builders/src/messages/embed/UnsafeEmbed.ts b/packages/builders/src/messages/embed/UnsafeEmbed.ts index a08d6af65552..ae4a6c1cf822 100644 --- a/packages/builders/src/messages/embed/UnsafeEmbed.ts +++ b/packages/builders/src/messages/embed/UnsafeEmbed.ts @@ -6,6 +6,10 @@ import type { APIEmbedImage, APIEmbedVideo, } from 'discord-api-types/v9'; +import type { Equatable } from '../../util/equatable'; +import isEqual from 'fast-deep-equal'; + +export type RGBTuple = [red: number, green: number, blue: number]; export interface IconData { /** @@ -36,7 +40,7 @@ export interface EmbedImageData extends Omit { /** * Represents a non-validated embed in a message (image/video preview, rich embed, etc.) */ -export class UnsafeEmbed { +export class UnsafeEmbed implements Equatable { protected data: APIEmbed; public constructor(data: APIEmbed = {}) { @@ -164,6 +168,13 @@ export class UnsafeEmbed { ); } + /** + * The hex color of the current color of the embed + */ + public get hexColor() { + return typeof this.data.color === 'number' ? `#${this.data.color.toString(16).padStart(6, '0')}` : this.data.color; + } + /** * Adds a field to the embed (max 25) * @@ -228,7 +239,12 @@ export class UnsafeEmbed { * * @param color The color of the embed */ - public setColor(color: number | null): this { + public setColor(color: number | RGBTuple | null): this { + if (Array.isArray(color)) { + const [red, green, blue] = color; + this.data.color = (red << 16) + (green << 8) + blue; + return this; + } this.data.color = color ?? undefined; return this; } @@ -315,6 +331,13 @@ export class UnsafeEmbed { return { ...this.data }; } + public equals(other: UnsafeEmbed | APIEmbed) { + const { image: thisImage, thumbnail: thisThumbnail, ...thisData } = this.data; + const data = other instanceof UnsafeEmbed ? other.data : other; + const { image, thumbnail, ...otherData } = data; + return isEqual(otherData, thisData) && image?.url === thisImage?.url && thumbnail?.url === thisThumbnail?.url; + } + /** * Normalizes field input and resolves strings * diff --git a/packages/builders/src/util/equatable.ts b/packages/builders/src/util/equatable.ts new file mode 100644 index 000000000000..98a92af4532d --- /dev/null +++ b/packages/builders/src/util/equatable.ts @@ -0,0 +1,14 @@ +export interface Equatable { + /** + * Whether or not this is equal to another structure + */ + equals: (other: T) => boolean; +} + +/** + * Indicates if an object is equatable or not. + * @param maybeEquatable The object to check against + */ +export function isEquatable(maybeEquatable: unknown): maybeEquatable is Equatable { + return maybeEquatable !== null && typeof maybeEquatable === 'object' && 'equals' in maybeEquatable; +} diff --git a/yarn.lock b/yarn.lock index 479404d592ba..5cd03c547587 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1769,6 +1769,7 @@ __metadata: eslint-config-marine: ^9.3.2 eslint-config-prettier: ^8.3.0 eslint-plugin-prettier: ^4.0.0 + fast-deep-equal: ^3.1.3 jest: ^27.5.1 prettier: ^2.5.1 ts-mixer: ^6.0.0