diff --git a/packages/builders/__tests__/components/fileUpload.test.ts b/packages/builders/__tests__/components/fileUpload.test.ts new file mode 100644 index 000000000000..19b5113b1621 --- /dev/null +++ b/packages/builders/__tests__/components/fileUpload.test.ts @@ -0,0 +1,59 @@ +import type { APIFileUploadComponent } from 'discord-api-types/v10'; +import { ComponentType } from 'discord-api-types/v10'; +import { describe, test, expect } from 'vitest'; +import { FileUploadBuilder } from '../../src/components/fileUpload/FileUpload.js'; + +const fileUploadComponent = () => new FileUploadBuilder(); + +describe('File Upload Components', () => { + describe('Assertion Tests', () => { + test('GIVEN valid fields THEN builder does not throw', () => { + expect(() => { + fileUploadComponent().setCustomId('foobar').toJSON(); + }).not.toThrowError(); + + expect(() => { + fileUploadComponent().setCustomId('foobar').setMinValues(2).setMaxValues(9).toJSON(); + }).not.toThrowError(); + }); + }); + + test('GIVEN invalid fields THEN builder throws', () => { + expect(() => fileUploadComponent().toJSON()).toThrowError(); + + expect(() => fileUploadComponent().setCustomId('test').setId(4.4).toJSON()).toThrowError(); + + expect(() => { + fileUploadComponent().setCustomId('a'.repeat(500)).toJSON(); + }).toThrowError(); + + expect(() => { + fileUploadComponent().setCustomId('a').setMaxValues(55).toJSON(); + }).toThrowError(); + + expect(() => { + fileUploadComponent().setCustomId('a').setMinValues(-1).toJSON(); + }).toThrowError(); + }); + + test('GIVEN valid input THEN valid JSON outputs are given', () => { + const fileUploadData = { + type: ComponentType.FileUpload, + custom_id: 'custom id', + min_values: 5, + max_values: 6, + required: false, + } satisfies APIFileUploadComponent; + + expect(new FileUploadBuilder(fileUploadData).toJSON()).toEqual(fileUploadData); + + expect( + fileUploadComponent() + .setCustomId(fileUploadData.custom_id) + .setMaxValues(fileUploadData.max_values) + .setMinValues(fileUploadData.min_values) + .setRequired(fileUploadData.required) + .toJSON(), + ).toEqual(fileUploadData); + }); +}); diff --git a/packages/builders/__tests__/components/label.test.ts b/packages/builders/__tests__/components/label.test.ts index fbe12093610b..45d67c597c39 100644 --- a/packages/builders/__tests__/components/label.test.ts +++ b/packages/builders/__tests__/components/label.test.ts @@ -1,4 +1,9 @@ -import type { APILabelComponent, APIStringSelectComponent, APITextInputComponent } from 'discord-api-types/v10'; +import type { + APIFileUploadComponent, + APILabelComponent, + APIStringSelectComponent, + APITextInputComponent, +} from 'discord-api-types/v10'; import { ComponentType, TextInputStyle } from 'discord-api-types/v10'; import { describe, test, expect } from 'vitest'; import { LabelBuilder } from '../../src/index.js'; @@ -27,6 +32,14 @@ describe('Label components', () => { ) .toJSON(), ).not.toThrow(); + + expect(() => + new LabelBuilder() + .setLabel('label') + .setId(5) + .setFileUploadComponent((fileUpload) => fileUpload.setCustomId('test')) + .toJSON(), + ).not.toThrow(); }); test('GIVEN invalid fields THEN build does throw', () => { @@ -40,6 +53,13 @@ describe('Label components', () => { .setStringSelectMenuComponent((stringSelectMenu) => stringSelectMenu) .toJSON(), ).toThrow(); + + expect(() => + new LabelBuilder() + .setLabel('l'.repeat(1_000)) + .setFileUploadComponent((fileUpload) => fileUpload) + .toJSON(), + ).toThrow(); }); test('GIVEN valid input THEN valid JSON outputs are given', () => { @@ -73,6 +93,19 @@ describe('Label components', () => { id: 5, } satisfies APILabelComponent; + const labelWithFileUploadData = { + type: ComponentType.Label, + component: { + type: ComponentType.FileUpload, + custom_id: 'custom_id', + min_values: 9, + required: true, + } satisfies APIFileUploadComponent, + label: 'label', + description: 'description', + id: 5, + } satisfies APILabelComponent; + expect(new LabelBuilder(labelWithTextInputData).toJSON()).toEqual(labelWithTextInputData); expect(new LabelBuilder(labelWithStringSelectData).toJSON()).toEqual(labelWithStringSelectData); @@ -104,6 +137,15 @@ describe('Label components', () => { .setId(5) .toJSON(), ).toEqual(labelWithStringSelectData); + + expect( + new LabelBuilder() + .setFileUploadComponent((fileUpload) => fileUpload.setCustomId('custom_id').setMinValues(9).setRequired()) + .setLabel('label') + .setDescription('description') + .setId(5) + .toJSON(), + ).toEqual(labelWithFileUploadData); }); }); }); diff --git a/packages/builders/src/components/Components.ts b/packages/builders/src/components/Components.ts index 73736b777d01..8a9f7357a63f 100644 --- a/packages/builders/src/components/Components.ts +++ b/packages/builders/src/components/Components.ts @@ -17,6 +17,7 @@ import { } from './button/CustomIdButton.js'; import { LinkButtonBuilder } from './button/LinkButton.js'; import { PremiumButtonBuilder } from './button/PremiumButton.js'; +import { FileUploadBuilder } from './fileUpload/FileUpload.js'; import { LabelBuilder } from './label/Label.js'; import { ChannelSelectMenuBuilder } from './selectMenu/ChannelSelectMenu.js'; import { MentionableSelectMenuBuilder } from './selectMenu/MentionableSelectMenu.js'; @@ -55,7 +56,11 @@ export type MessageComponentBuilder = /** * The builders that may be used for modals. */ -export type ModalComponentBuilder = ActionRowBuilder | LabelBuilder | ModalActionRowComponentBuilder; +export type ModalComponentBuilder = + | ActionRowBuilder + | FileUploadBuilder + | LabelBuilder + | ModalActionRowComponentBuilder; /** * Any button builder @@ -92,7 +97,7 @@ export type AnyActionRowComponentBuilder = MessageActionRowComponentBuilder | Mo /** * Any modal component builder. */ -export type AnyModalComponentBuilder = LabelBuilder | TextDisplayBuilder; +export type AnyModalComponentBuilder = FileUploadBuilder | LabelBuilder | TextDisplayBuilder; /** * Components here are mapped to their respective builder. @@ -162,6 +167,10 @@ export interface MappedComponentTypes { * The label component type is associated with a {@link LabelBuilder}. */ [ComponentType.Label]: LabelBuilder; + /** + * The file upload component type is associated with a {@link FileUploadBuilder}. + */ + [ComponentType.FileUpload]: FileUploadBuilder; } /** @@ -192,8 +201,6 @@ export function createComponentBuilder( return data; } - // should be removed in https://github.com/discordjs/discord.js/pull/11108 - // eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check switch (data.type) { case ComponentType.ActionRow: return new ActionRowBuilder(data); @@ -227,9 +234,10 @@ export function createComponentBuilder( return new ContainerBuilder(data); case ComponentType.Label: return new LabelBuilder(data); + case ComponentType.FileUpload: + return new FileUploadBuilder(data); default: - // should be uncommented in https://github.com/discordjs/discord.js/pull/11108 - /* // @ts-expect-error This case can still occur if we get a newer unsupported component type */ + // @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}`); } } diff --git a/packages/builders/src/components/fileUpload/Assertions.ts b/packages/builders/src/components/fileUpload/Assertions.ts new file mode 100644 index 000000000000..9d5f09d19be3 --- /dev/null +++ b/packages/builders/src/components/fileUpload/Assertions.ts @@ -0,0 +1,12 @@ +import { ComponentType } from 'discord-api-types/v10'; +import { z } from 'zod'; +import { customIdPredicate } from '../../Assertions'; + +export const fileUploadPredicate = z.object({ + type: z.literal(ComponentType.FileUpload), + id: z.int().min(0).optional(), + custom_id: customIdPredicate, + min_values: z.int().min(0).max(10).optional(), + max_values: z.int().min(1).max(10).optional(), + required: z.boolean().optional(), +}); diff --git a/packages/builders/src/components/fileUpload/FileUpload.ts b/packages/builders/src/components/fileUpload/FileUpload.ts new file mode 100644 index 000000000000..60e88be6087d --- /dev/null +++ b/packages/builders/src/components/fileUpload/FileUpload.ts @@ -0,0 +1,109 @@ +import type { APIFileUploadComponent } from 'discord-api-types/v10'; +import { ComponentType } from 'discord-api-types/v10'; +import { validate } from '../../util/validation.js'; +import { ComponentBuilder } from '../Component.js'; +import { fileUploadPredicate } from './Assertions.js'; + +/** + * A builder that creates API-compatible JSON data for file uploads. + */ +export class FileUploadBuilder extends ComponentBuilder { + /** + * @internal + */ + protected readonly data: Partial; + + /** + * Creates a new file upload. + * + * @param data - The API data to create this file upload with + * @example + * Creating a file upload from an API data object: + * ```ts + * const fileUpload = new FileUploadBuilder({ + * custom_id: "file_upload", + * min_values: 2, + * max_values: 5, + * }); + * ``` + * @example + * Creating a file upload using setters and API data: + * ```ts + * const fileUpload = new FileUploadBuilder({ + * custom_id: "file_upload", + * min_values: 2, + * max_values: 5, + * }).setRequired(); + * ``` + */ + public constructor(data: Partial = {}) { + super(); + this.data = { ...structuredClone(data), type: ComponentType.FileUpload }; + } + + /** + * Sets the custom id for this file upload. + * + * @param customId - The custom id to use + */ + public setCustomId(customId: string) { + this.data.custom_id = customId; + return this; + } + + /** + * Sets the minimum number of file uploads required. + * + * @param minValues - The minimum values that must be uploaded + */ + public setMinValues(minValues: number) { + this.data.min_values = minValues; + return this; + } + + /** + * Clears the minimum values. + */ + public clearMinValues() { + this.data.min_values = undefined; + return this; + } + + /** + * Sets the maximum number of file uploads required. + * + * @param maxValues - The maximum values that must be uploaded + */ + public setMaxValues(maxValues: number) { + this.data.max_values = maxValues; + return this; + } + + /** + * Clears the maximum values. + */ + public clearMaxValues() { + this.data.max_values = undefined; + return this; + } + + /** + * Sets whether this file upload is required. + * + * @param required - Whether this file upload is required + */ + public setRequired(required = true) { + this.data.required = required; + return this; + } + + /** + * {@inheritDoc ComponentBuilder.toJSON} + */ + public toJSON(validationOverride?: boolean): APIFileUploadComponent { + const clone = structuredClone(this.data); + validate(fileUploadPredicate, clone, validationOverride); + + return clone as APIFileUploadComponent; + } +} diff --git a/packages/builders/src/components/label/Assertions.ts b/packages/builders/src/components/label/Assertions.ts index e9a58af1ceca..fdb8e6e9b931 100644 --- a/packages/builders/src/components/label/Assertions.ts +++ b/packages/builders/src/components/label/Assertions.ts @@ -8,6 +8,7 @@ import { selectMenuStringPredicate, selectMenuUserPredicate, } from '../Assertions'; +import { fileUploadPredicate } from '../fileUpload/Assertions'; import { textInputPredicate } from '../textInput/Assertions'; export const labelPredicate = z.object({ @@ -22,5 +23,6 @@ export const labelPredicate = z.object({ selectMenuRolePredicate, selectMenuMentionablePredicate, selectMenuChannelPredicate, + fileUploadPredicate, ]), }); diff --git a/packages/builders/src/components/label/Label.ts b/packages/builders/src/components/label/Label.ts index 4895975d1ea5..26819471a404 100644 --- a/packages/builders/src/components/label/Label.ts +++ b/packages/builders/src/components/label/Label.ts @@ -1,5 +1,6 @@ import type { APIChannelSelectComponent, + APIFileUploadComponent, APILabelComponent, APIMentionableSelectComponent, APIRoleSelectComponent, @@ -12,6 +13,7 @@ import { resolveBuilder } from '../../util/resolveBuilder.js'; import { validate } from '../../util/validation.js'; import { ComponentBuilder } from '../Component.js'; import { createComponentBuilder } from '../Components.js'; +import { FileUploadBuilder } from '../fileUpload/FileUpload.js'; import { ChannelSelectMenuBuilder } from '../selectMenu/ChannelSelectMenu.js'; import { MentionableSelectMenuBuilder } from '../selectMenu/MentionableSelectMenu.js'; import { RoleSelectMenuBuilder } from '../selectMenu/RoleSelectMenu.js'; @@ -23,6 +25,7 @@ import { labelPredicate } from './Assertions.js'; export interface LabelBuilderData extends Partial> { component?: | ChannelSelectMenuBuilder + | FileUploadBuilder | MentionableSelectMenuBuilder | RoleSelectMenuBuilder | StringSelectMenuBuilder @@ -67,7 +70,6 @@ export class LabelBuilder extends ComponentBuilder { this.data = { ...structuredClone(rest), - // @ts-expect-error fixed in https://github.com/discordjs/discord.js/pull/11108 component: component ? createComponentBuilder(component) : undefined, type: ComponentType.Label, }; @@ -182,6 +184,18 @@ export class LabelBuilder extends ComponentBuilder { return this; } + /** + * Sets a file upload component to this label. + * + * @param input - A function that returns a component builder or an already built builder + */ + public setFileUploadComponent( + input: APIFileUploadComponent | FileUploadBuilder | ((builder: FileUploadBuilder) => FileUploadBuilder), + ): this { + this.data.component = resolveBuilder(input, FileUploadBuilder); + return this; + } + /** * {@inheritDoc ComponentBuilder.toJSON} */ diff --git a/packages/builders/src/index.ts b/packages/builders/src/index.ts index 7e3f83f67027..7a99a3e56e9e 100644 --- a/packages/builders/src/index.ts +++ b/packages/builders/src/index.ts @@ -4,6 +4,9 @@ export * from './components/button/CustomIdButton.js'; export * from './components/button/LinkButton.js'; export * from './components/button/PremiumButton.js'; +export * from './components/fileUpload/FileUpload.js'; +export * from './components/fileUpload/Assertions.js'; + export * from './components/label/Label.js'; export * from './components/label/Assertions.js'; diff --git a/packages/builders/src/interactions/modals/Modal.ts b/packages/builders/src/interactions/modals/Modal.ts index b77b62205c3c..882125fe751b 100644 --- a/packages/builders/src/interactions/modals/Modal.ts +++ b/packages/builders/src/interactions/modals/Modal.ts @@ -44,7 +44,6 @@ export class ModalBuilder implements JSONEncodable createComponentBuilder(component)), }; }