Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
59 changes: 59 additions & 0 deletions packages/builders/__tests__/components/fileUpload.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
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);
});
});
});
20 changes: 14 additions & 6 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 file upload component type is associated with a {@link FileUploadBuilder}.
*/
[ComponentType.FileUpload]: FileUploadBuilder;
}

/**
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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}`);
}
}
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.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(),
});
109 changes: 109 additions & 0 deletions packages/builders/src/components/fileUpload/FileUpload.ts
Original file line number Diff line number Diff line change
@@ -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<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,
* 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<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 @@ -8,6 +8,7 @@ import {
selectMenuStringPredicate,
selectMenuUserPredicate,
} from '../Assertions';
import { fileUploadPredicate } from '../fileUpload/Assertions';
import { textInputPredicate } from '../textInput/Assertions';

export const labelPredicate = z.object({
Expand All @@ -22,5 +23,6 @@ export const labelPredicate = z.object({
selectMenuRolePredicate,
selectMenuMentionablePredicate,
selectMenuChannelPredicate,
fileUploadPredicate,
]),
});
16 changes: 15 additions & 1 deletion 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 @@ -67,7 +70,6 @@ export class LabelBuilder extends ComponentBuilder<APILabelComponent> {

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,
};
Expand Down Expand Up @@ -182,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
Loading