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/package.json b/packages/builders/package.json index e2a1a184897b..805a79c0f958 100644 --- a/packages/builders/package.json +++ b/packages/builders/package.json @@ -66,7 +66,7 @@ "funding": "https://github.com/discordjs/discord.js?sponsor", "dependencies": { "@discordjs/util": "workspace:^", - "discord-api-types": "^0.38.23", + "discord-api-types": "0.38.26-next.93decca.1758147813", "ts-mixer": "^6.0.4", "tslib": "^2.8.1", "zod": "^4.0.17" diff --git a/packages/builders/src/components/Components.ts b/packages/builders/src/components/Components.ts index 8abba582cacc..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; } /** @@ -225,6 +234,8 @@ export function createComponentBuilder( return new ContainerBuilder(data); case ComponentType.Label: return new LabelBuilder(data); + case ComponentType.FileUpload: + return new FileUploadBuilder(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}`); 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 0c62da1bf497..a9ca6d84b6b8 100644 --- a/packages/builders/src/components/label/Assertions.ts +++ b/packages/builders/src/components/label/Assertions.ts @@ -7,6 +7,7 @@ import { selectMenuStringPredicate, selectMenuUserPredicate, } from '../Assertions'; +import { fileUploadPredicate } from '../fileUpload/Assertions'; import { textInputPredicate } from '../textInput/Assertions'; export const labelPredicate = z.object({ @@ -20,5 +21,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 a9353f7e5f62..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 @@ -181,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/core/package.json b/packages/core/package.json index 8d18f60620a6..18bec57fe0a8 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -70,7 +70,7 @@ "@discordjs/ws": "workspace:^", "@sapphire/snowflake": "^3.5.5", "@vladfrangu/async_event_emitter": "^2.4.6", - "discord-api-types": "^0.38.23" + "discord-api-types": "0.38.26-next.93decca.1758147813" }, "devDependencies": { "@discordjs/api-extractor": "workspace:^", diff --git a/packages/discord.js/package.json b/packages/discord.js/package.json index 290ab1048846..4be2b196882d 100644 --- a/packages/discord.js/package.json +++ b/packages/discord.js/package.json @@ -74,7 +74,7 @@ "@discordjs/ws": "workspace:^", "@sapphire/snowflake": "3.5.5", "@vladfrangu/async_event_emitter": "^2.4.6", - "discord-api-types": "^0.38.23", + "discord-api-types": "0.38.26-next.93decca.1758147813", "fast-deep-equal": "3.1.3", "lodash.snakecase": "4.1.1", "magic-bytes.js": "^1.12.1", diff --git a/packages/formatters/package.json b/packages/formatters/package.json index 8076678be914..4021814f7c46 100644 --- a/packages/formatters/package.json +++ b/packages/formatters/package.json @@ -55,7 +55,7 @@ "homepage": "https://discord.js.org", "funding": "https://github.com/discordjs/discord.js?sponsor", "dependencies": { - "discord-api-types": "^0.38.23" + "discord-api-types": "0.38.26-next.93decca.1758147813" }, "devDependencies": { "@discordjs/api-extractor": "workspace:^", diff --git a/packages/next/package.json b/packages/next/package.json index 4af527c9ae2b..d657a30d9b1d 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -72,7 +72,7 @@ "@discordjs/rest": "workspace:^", "@discordjs/util": "workspace:^", "@discordjs/ws": "workspace:^", - "discord-api-types": "^0.38.23" + "discord-api-types": "0.38.26-next.93decca.1758147813" }, "devDependencies": { "@discordjs/api-extractor": "workspace:^", diff --git a/packages/rest/package.json b/packages/rest/package.json index 3a9f349aaf39..eb778d10c0bc 100644 --- a/packages/rest/package.json +++ b/packages/rest/package.json @@ -88,7 +88,7 @@ "@sapphire/async-queue": "^1.5.5", "@sapphire/snowflake": "^3.5.5", "@vladfrangu/async_event_emitter": "^2.4.6", - "discord-api-types": "^0.38.23", + "discord-api-types": "0.38.26-next.93decca.1758147813", "magic-bytes.js": "^1.12.1", "tslib": "^2.8.1", "undici": "7.11.0", diff --git a/packages/structures/package.json b/packages/structures/package.json index 2e4f64d992b9..9b65b2362a50 100644 --- a/packages/structures/package.json +++ b/packages/structures/package.json @@ -63,7 +63,7 @@ "dependencies": { "@discordjs/formatters": "workspace:^", "@sapphire/snowflake": "^3.5.5", - "discord-api-types": "^0.38.23" + "discord-api-types": "0.38.26-next.93decca.1758147813" }, "devDependencies": { "@discordjs/api-extractor": "workspace:^", diff --git a/packages/voice/package.json b/packages/voice/package.json index 657d679b19cc..69b1a11a0524 100644 --- a/packages/voice/package.json +++ b/packages/voice/package.json @@ -64,7 +64,7 @@ "funding": "https://github.com/discordjs/discord.js?sponsor", "dependencies": { "@types/ws": "^8.18.1", - "discord-api-types": "^0.38.23", + "discord-api-types": "0.38.26-next.93decca.1758147813", "prism-media": "^1.3.5", "tslib": "^2.8.1", "ws": "^8.18.3" diff --git a/packages/ws/package.json b/packages/ws/package.json index c8ebb05494e5..d39cd9515c2f 100644 --- a/packages/ws/package.json +++ b/packages/ws/package.json @@ -78,7 +78,7 @@ "@sapphire/async-queue": "^1.5.5", "@types/ws": "^8.18.1", "@vladfrangu/async_event_emitter": "^2.4.6", - "discord-api-types": "^0.38.23", + "discord-api-types": "0.38.26-next.93decca.1758147813", "tslib": "^2.8.1", "ws": "^8.18.3" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c3be7b7f5b84..476f707bfc89 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -765,8 +765,8 @@ importers: specifier: workspace:^ version: link:../util discord-api-types: - specifier: ^0.38.23 - version: 0.38.23 + specifier: 0.38.26-next.93decca.1758147813 + version: 0.38.26-next.93decca.1758147813 ts-mixer: specifier: ^6.0.4 version: 6.0.4 @@ -895,8 +895,8 @@ importers: specifier: ^2.4.6 version: 2.4.6 discord-api-types: - specifier: ^0.38.23 - version: 0.38.23 + specifier: 0.38.26-next.93decca.1758147813 + version: 0.38.26-next.93decca.1758147813 devDependencies: '@discordjs/api-extractor': specifier: workspace:^ @@ -1029,8 +1029,8 @@ importers: specifier: ^2.4.6 version: 2.4.6 discord-api-types: - specifier: ^0.38.23 - version: 0.38.23 + specifier: 0.38.26-next.93decca.1758147813 + version: 0.38.26-next.93decca.1758147813 fast-deep-equal: specifier: 3.1.3 version: 3.1.3 @@ -1154,8 +1154,8 @@ importers: packages/formatters: dependencies: discord-api-types: - specifier: ^0.38.23 - version: 0.38.23 + specifier: 0.38.26-next.93decca.1758147813 + version: 0.38.26-next.93decca.1758147813 devDependencies: '@discordjs/api-extractor': specifier: workspace:^ @@ -1230,8 +1230,8 @@ importers: specifier: workspace:^ version: link:../ws discord-api-types: - specifier: ^0.38.23 - version: 0.38.23 + specifier: 0.38.26-next.93decca.1758147813 + version: 0.38.26-next.93decca.1758147813 devDependencies: '@discordjs/api-extractor': specifier: workspace:^ @@ -1416,8 +1416,8 @@ importers: specifier: ^2.4.6 version: 2.4.6 discord-api-types: - specifier: ^0.38.23 - version: 0.38.23 + specifier: 0.38.26-next.93decca.1758147813 + version: 0.38.26-next.93decca.1758147813 magic-bytes.js: specifier: ^1.12.1 version: 1.12.1 @@ -1565,8 +1565,8 @@ importers: specifier: ^3.5.5 version: 3.5.5 discord-api-types: - specifier: ^0.38.23 - version: 0.38.23 + specifier: 0.38.26-next.93decca.1758147813 + version: 0.38.26-next.93decca.1758147813 devDependencies: '@discordjs/api-extractor': specifier: workspace:^ @@ -1783,8 +1783,8 @@ importers: specifier: ^8.18.1 version: 8.18.1 discord-api-types: - specifier: ^0.38.23 - version: 0.38.23 + specifier: 0.38.26-next.93decca.1758147813 + version: 0.38.26-next.93decca.1758147813 prism-media: specifier: ^1.3.5 version: 1.3.5(@discordjs/opus@0.9.0(encoding@0.1.13)) @@ -1874,8 +1874,8 @@ importers: specifier: ^2.4.6 version: 2.4.6 discord-api-types: - specifier: ^0.38.23 - version: 0.38.23 + specifier: 0.38.26-next.93decca.1758147813 + version: 0.38.26-next.93decca.1758147813 tslib: specifier: ^2.8.1 version: 2.8.1 @@ -7725,8 +7725,8 @@ packages: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} - discord-api-types@0.38.23: - resolution: {integrity: sha512-C8VjK0yxBUq1dakxGpUXQm4VSC7R+aaD2SIr3paj2a0bP/LRok1AqHiezp30GruK6Ba9FtQAKqYUMJPzsqv7IQ==} + discord-api-types@0.38.26-next.93decca.1758147813: + resolution: {integrity: sha512-G2+3c8rPWzxlisIK/7G4ZF40iAhgXJSxMNFDHpNW2+tN57kaPM9d+TJgTqBOwOTrFjUBEzNNyno0CN9wxI5UoQ==} dmd@6.2.3: resolution: {integrity: sha512-SIEkjrG7cZ9GWZQYk/mH+mWtcRPly/3ibVuXO/tP/MFoWz6KiRK77tSMq6YQBPl7RljPtXPQ/JhxbNuCdi1bNw==} @@ -20437,7 +20437,7 @@ snapshots: dependencies: path-type: 4.0.0 - discord-api-types@0.38.23: {} + discord-api-types@0.38.26-next.93decca.1758147813: {} dmd@6.2.3: dependencies: