Skip to content
Draft
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions packages/builders/__tests__/components/fileUpload.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
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('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);
});
});
44 changes: 43 additions & 1 deletion packages/builders/__tests__/components/label.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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', () => {
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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);
});
});
});
2 changes: 1 addition & 1 deletion packages/builders/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
15 changes: 13 additions & 2 deletions packages/builders/src/components/Components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -162,6 +167,10 @@ export interface MappedComponentTypes {
* The label component type is associated with a {@link LabelBuilder}.
*/
[ComponentType.Label]: LabelBuilder;
/**
* The label component type is associated with a {@link FileUploadBuilder}.
*/
[ComponentType.FileUpload]: FileUploadBuilder;
}

/**
Expand Down Expand Up @@ -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}`);
Expand Down
12 changes: 12 additions & 0 deletions packages/builders/src/components/fileUpload/Assertions.ts
Original file line number Diff line number Diff line change
@@ -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.number().optional(),
custom_id: customIdPredicate,
min_values: z.number().min(0).max(10).optional(),
max_values: z.number().min(1).max(10).optional(),
required: z.boolean().optional(),
});
107 changes: 107 additions & 0 deletions packages/builders/src/components/fileUpload/FileUpload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
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<APIFileUploadComponent> {
/**
* @internal
*/
protected readonly data: Partial<APIFileUploadComponent>;

/**
* 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,
* });
* ```
* @example
* Creating a file upload using setters and API data:
* ```ts
* const fileUpload = new FileUploadBuilder({
* custom_id: "file_upload",
* min_values: 2,
* }).setRequired();
* ```
*/
public constructor(data: Partial<APIFileUploadComponent> = {}) {
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;
}
}
2 changes: 2 additions & 0 deletions packages/builders/src/components/label/Assertions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
selectMenuStringPredicate,
selectMenuUserPredicate,
} from '../Assertions';
import { fileUploadPredicate } from '../fileUpload/Assertions';
import { textInputPredicate } from '../textInput/Assertions';

export const labelPredicate = z.object({
Expand All @@ -20,5 +21,6 @@ export const labelPredicate = z.object({
selectMenuRolePredicate,
selectMenuMentionablePredicate,
selectMenuChannelPredicate,
fileUploadPredicate,
]),
});
15 changes: 15 additions & 0 deletions packages/builders/src/components/label/Label.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type {
APIChannelSelectComponent,
APIFileUploadComponent,
APILabelComponent,
APIMentionableSelectComponent,
APIRoleSelectComponent,
Expand All @@ -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';
Expand All @@ -23,6 +25,7 @@ import { labelPredicate } from './Assertions.js';
export interface LabelBuilderData extends Partial<Omit<APILabelComponent, 'component'>> {
component?:
| ChannelSelectMenuBuilder
| FileUploadBuilder
| MentionableSelectMenuBuilder
| RoleSelectMenuBuilder
| StringSelectMenuBuilder
Expand Down Expand Up @@ -181,6 +184,18 @@ export class LabelBuilder extends ComponentBuilder<APILabelComponent> {
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}
*/
Expand Down
3 changes: 3 additions & 0 deletions packages/builders/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:^",
Expand Down
2 changes: 1 addition & 1 deletion packages/discord.js/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading