diff --git a/packages/builders/__tests__/components/checkbox.test.ts b/packages/builders/__tests__/components/checkbox.test.ts new file mode 100644 index 000000000..4959d8450 --- /dev/null +++ b/packages/builders/__tests__/components/checkbox.test.ts @@ -0,0 +1,415 @@ +import { ComponentType, type APICheckboxComponent } from 'discord-api-types/v10'; +import { describe, test, expect } from 'vitest'; +import { LabelBuilder } from '../../src'; +import { CheckboxBuilder } from '../../src/components/checkbox/Checkbox'; +import { CheckboxGroupBuilder } from '../../src/components/checkbox/CheckboxGroup'; +import { CheckboxGroupOptionBuilder } from '../../src/components/checkbox/CheckboxGroupOption'; +import { RadioGroupBuilder } from '../../src/components/checkbox/RadioGroup'; +import { RadioGroupOptionBuilder } from '../../src/components/checkbox/RadioGroupOption'; + +const longStr = ':3'.repeat(5_000); + +const fiveCheckboxOptions = [ + new CheckboxGroupOptionBuilder().setLabel('Option 1').setValue('option_1'), + new CheckboxGroupOptionBuilder().setLabel('Option 2').setValue('option_2'), + new CheckboxGroupOptionBuilder().setLabel('Option 3').setValue('option_3'), + new CheckboxGroupOptionBuilder().setLabel('Option 4').setValue('option_4'), + new CheckboxGroupOptionBuilder().setLabel('Option 5').setValue('option_5'), +]; + +const elevenCheckboxOptions = [ + new CheckboxGroupOptionBuilder().setLabel('Option 1').setValue('option_1'), + new CheckboxGroupOptionBuilder().setLabel('Option 2').setValue('option_2'), + new CheckboxGroupOptionBuilder().setLabel('Option 3').setValue('option_3'), + new CheckboxGroupOptionBuilder().setLabel('Option 4').setValue('option_4'), + new CheckboxGroupOptionBuilder().setLabel('Option 5').setValue('option_5'), + new CheckboxGroupOptionBuilder().setLabel('Option 6').setValue('option_6'), + new CheckboxGroupOptionBuilder().setLabel('Option 7').setValue('option_7'), + new CheckboxGroupOptionBuilder().setLabel('Option 8').setValue('option_8'), + new CheckboxGroupOptionBuilder().setLabel('Option 9').setValue('option_9'), + new CheckboxGroupOptionBuilder().setLabel('Option 10').setValue('option_10'), + new CheckboxGroupOptionBuilder().setLabel('Option 11').setValue('option_11'), +]; + +const fiveRadioOptions = [ + new RadioGroupOptionBuilder().setLabel('Option 1').setValue('option_1'), + new RadioGroupOptionBuilder().setLabel('Option 2').setValue('option_2'), + new RadioGroupOptionBuilder().setLabel('Option 3').setValue('option_3'), + new RadioGroupOptionBuilder().setLabel('Option 4').setValue('option_4'), + new RadioGroupOptionBuilder().setLabel('Option 5').setValue('option_5'), +]; + +const elevenRadioOptions = [ + new RadioGroupOptionBuilder().setLabel('Option 1').setValue('option_1'), + new RadioGroupOptionBuilder().setLabel('Option 2').setValue('option_2'), + new RadioGroupOptionBuilder().setLabel('Option 3').setValue('option_3'), + new RadioGroupOptionBuilder().setLabel('Option 4').setValue('option_4'), + new RadioGroupOptionBuilder().setLabel('Option 5').setValue('option_5'), + new RadioGroupOptionBuilder().setLabel('Option 6').setValue('option_6'), + new RadioGroupOptionBuilder().setLabel('Option 7').setValue('option_7'), + new RadioGroupOptionBuilder().setLabel('Option 8').setValue('option_8'), + new RadioGroupOptionBuilder().setLabel('Option 9').setValue('option_9'), + new RadioGroupOptionBuilder().setLabel('Option 10').setValue('option_10'), + new RadioGroupOptionBuilder().setLabel('Option 11').setValue('option_11'), +]; + +describe('Checkbox Components', () => { + describe('CheckboxBuilder', () => { + test('Valid builder does not throw.', () => { + expect(() => new CheckboxBuilder().setCustomId('checkbox').toJSON()).not.toThrowError(); + expect(() => new CheckboxBuilder().setCustomId('checkbox').setDefault(true).toJSON()).not.toThrowError(); + }); + + test('Invalid builder does throw.', () => { + expect(() => new CheckboxBuilder().toJSON()).toThrowError(); + expect(() => new CheckboxBuilder().setDefault(true).toJSON()).toThrowError(); + expect(() => new CheckboxBuilder().setCustomId(longStr).toJSON()).toThrowError(); + }); + + test('API data equals toJSON().', () => { + const checkboxData = { + type: ComponentType.Checkbox, + custom_id: 'checkbox', + default: true, + } satisfies APICheckboxComponent; + + expect(new CheckboxBuilder(checkboxData).toJSON()).toEqual(checkboxData); + + expect(new CheckboxBuilder().setCustomId('checkbox').setDefault(true).toJSON()).toEqual(checkboxData); + }); + }); + + describe('CheckboxGroupBuilder', () => { + test('Valid builder does not throw.', () => { + expect(() => + new CheckboxGroupBuilder({ + custom_id: 'checkbox_group', + options: fiveCheckboxOptions.map((option) => option.toJSON()), + }).toJSON(), + ).not.toThrowError(); + expect(() => + new CheckboxGroupBuilder().setCustomId('checkbox_group').setOptions(fiveCheckboxOptions).toJSON(), + ).not.toThrowError(); + expect(() => + new CheckboxGroupBuilder() + .setCustomId('checkbox_group') + .setOptions([ + new CheckboxGroupOptionBuilder().setLabel('Option 1').setValue('option_1'), + new CheckboxGroupOptionBuilder().setLabel('Option 2').setValue('option_2'), + ]) + .toJSON(), + ).not.toThrowError(); + expect(() => + new CheckboxGroupBuilder().setCustomId('checkbox_group').addOptions(fiveCheckboxOptions).toJSON(), + ).not.toThrowError(); + expect(() => + new CheckboxGroupBuilder() + .setCustomId('checkbox_group') + .setMinValues(1) + .setMaxValues(2) + .setOptions([ + new CheckboxGroupOptionBuilder().setLabel('Option 1').setValue('option_1'), + new CheckboxGroupOptionBuilder().setLabel('Option 2').setValue('option_2'), + ]) + .toJSON(), + ).not.toThrowError(); + expect(() => + new CheckboxGroupBuilder() + .setCustomId('checkbox_group') + .setOptions(fiveCheckboxOptions) + .spliceOptions(2, 1, ...elevenCheckboxOptions.slice(7, 9)) + .spliceOptions(0, 1, { label: 'New Option', value: 'new_option' }), + ).not.toThrowError(); + }); + + test('Invalid builder does throw.', () => { + expect(() => new CheckboxGroupBuilder().toJSON()).toThrowError(); + expect(() => new CheckboxGroupBuilder().addOptions([]).toJSON()).toThrowError(); + expect(() => new CheckboxGroupBuilder().setMinValues(2).setMaxValues(1).toJSON()).toThrowError(); + expect(() => + new CheckboxGroupBuilder().setMinValues(2).setMaxValues(1).addOptions(fiveCheckboxOptions).toJSON(), + ).toThrowError(); + expect(() => + new CheckboxGroupBuilder() + .setCustomId('checkbox_group') + .setMinValues(2) + .setMaxValues(1) + .addOptions(fiveCheckboxOptions) + .toJSON(), + ).toThrowError(); + expect(() => + new CheckboxGroupBuilder().setCustomId('checkbox_group').setMinValues(5).setMaxValues(11).toJSON(), + ).toThrowError(); + expect(() => new CheckboxGroupBuilder().setCustomId('checkbox_group').setOptions([]).toJSON()).toThrowError(); + expect(() => + new CheckboxGroupBuilder() + .setCustomId('checkbox_group') + .setOptions(fiveCheckboxOptions) + .setMinValues(6) + .toJSON(), + ).toThrowError(); + expect(() => + new CheckboxGroupBuilder() + .setCustomId('checkbox_group') + .setOptions(fiveCheckboxOptions) + .setMaxValues(6) + .toJSON(), + ).toThrowError(); + expect(() => + new CheckboxGroupBuilder().setCustomId('checkbox_group').setOptions(elevenCheckboxOptions).toJSON(), + ).toThrowError(); + expect(() => + new CheckboxGroupBuilder() + .setCustomId('checkbox_group') + .setOptions(elevenCheckboxOptions) + .setMaxValues(12) + .toJSON(), + ).toThrowError(); + expect(() => + new CheckboxGroupBuilder().setCustomId(longStr).setOptions(fiveCheckboxOptions).toJSON(), + ).toThrowError(); + expect(() => + new CheckboxGroupBuilder() + .setCustomId('checkbox_group') + .setOptions(fiveCheckboxOptions) + .setMinValues(0) + .setRequired(true) + .toJSON(), + ).toThrowError(); + expect(() => + new CheckboxGroupBuilder() + .setCustomId('checkbox_group') + .setMaxValues(4) + .setOptions([ + new CheckboxGroupOptionBuilder().setLabel('Option 1').setValue('option_1').setDefault(true), + new CheckboxGroupOptionBuilder().setLabel('Option 2').setValue('option_2').setDefault(true), + new CheckboxGroupOptionBuilder().setLabel('Option 3').setValue('option_3').setDefault(true), + new CheckboxGroupOptionBuilder().setLabel('Option 4').setValue('option_4').setDefault(true), + new CheckboxGroupOptionBuilder().setLabel('Option 5').setValue('option_5').setDefault(true), + ]) + .toJSON(), + ).toThrowError(); + expect(() => + new CheckboxGroupBuilder() + .setCustomId('checkbox_group') + .setOptions(fiveCheckboxOptions) + .addOptions(fiveCheckboxOptions) + .toJSON(), + ).toThrowError(); + expect(() => + new CheckboxGroupBuilder() + .setCustomId('checkbox_group') + .setOptions(elevenCheckboxOptions.slice(0, 5)) + .addOptions(elevenCheckboxOptions.slice(5, 11)) + .toJSON(), + ).toThrowError(); + expect( + () => + new CheckboxGroupBuilder() + .setCustomId('checkbox_group') + .setOptions(fiveCheckboxOptions) + .spliceOptions(2, 1, new CheckboxGroupOptionBuilder().setLabel('Option 6')), // no value + ).toThrowError(); + expect(() => + new CheckboxGroupBuilder() + .setCustomId('checkbox_group') + .setOptions(fiveCheckboxOptions) + .spliceOptions(2, 1, { value: 'hi', label: longStr }), + ).toThrowError(); + expect(() => + new CheckboxGroupBuilder().setCustomId('checkbox_group').addOptions({ value: 'hi', label: longStr }), + ).toThrowError(); + }); + }); + + describe('CheckboxGroupOptionBuilder', () => { + test('Valid builder does not throw.', () => { + expect(() => + new CheckboxGroupOptionBuilder().setLabel('Option 1').setValue('option_1').toJSON(), + ).not.toThrowError(); + expect(() => + new CheckboxGroupOptionBuilder() + .setLabel('Option 2') + .setValue('option_2') + .setDescription('This is option 2') + .toJSON(), + ).not.toThrowError(); + expect(() => + new CheckboxGroupOptionBuilder().setLabel('Option 3').setValue('option_3').setDefault(true).toJSON(), + ).not.toThrowError(); + }); + + test('Invalid builder does throw.', () => { + expect(() => new CheckboxGroupOptionBuilder().toJSON()).toThrowError(); + expect(() => new CheckboxGroupOptionBuilder().setValue('option_1').toJSON()).toThrowError(); + expect(() => new CheckboxGroupOptionBuilder().setLabel('Option 1').toJSON()).toThrowError(); + expect(() => new CheckboxGroupOptionBuilder().setLabel(longStr).setValue('option_1').toJSON()).toThrowError(); + expect(() => new CheckboxGroupOptionBuilder().setLabel('Option 1').setValue(longStr).toJSON()).toThrowError(); + }); + + test('toJSON returns correct data.', () => { + const option = new CheckboxGroupOptionBuilder() + .setLabel('Option 1') + .setValue('option_1') + .setDescription('This is option 1') + .setDefault(true); + + expect(option.toJSON()).toEqual({ + label: 'Option 1', + value: 'option_1', + description: 'This is option 1', + default: true, + }); + }); + }); + + describe('RadioGroupBuilder', () => { + test('Valid builder does not throw.', () => { + expect(() => + new RadioGroupBuilder().setCustomId('radio_group').addOptions(fiveRadioOptions).toJSON(), + ).not.toThrowError(); + expect(() => + new RadioGroupBuilder() + .setCustomId('radio_group') + .setOptions([ + new RadioGroupOptionBuilder().setLabel('Option 1').setValue('option_1'), + new RadioGroupOptionBuilder().setLabel('Option 2').setValue('option_2'), + ]) + .toJSON(), + ).not.toThrowError(); + expect(() => + new RadioGroupBuilder().setCustomId('radio_group').addOptions(fiveRadioOptions).setRequired(false), + ).not.toThrowError(); + expect(() => + new RadioGroupBuilder().setCustomId('radio_group').addOptions(fiveRadioOptions).setRequired(true), + ).not.toThrowError(); + expect(() => new RadioGroupBuilder().setCustomId('radio_group').setOptions(fiveRadioOptions)).not.toThrowError(); + expect(() => + new RadioGroupBuilder() + .setCustomId('radio_group') + .setOptions(fiveRadioOptions) + .spliceOptions(2, 1, elevenRadioOptions.slice(7, 9)), + ).not.toThrowError(); + expect(() => + new RadioGroupBuilder() + .setCustomId('radio_group') + .addOptions(elevenRadioOptions.slice(0, 5)) + .spliceOptions(0, 1, { label: 'New Option', value: 'new_option' }), + ).not.toThrowError(); + expect(() => + new RadioGroupBuilder({ + custom_id: 'radio_group', + options: fiveRadioOptions.map((option) => option.toJSON()), + }).toJSON(), + ).not.toThrowError(); + expect(() => + new RadioGroupBuilder() + .setCustomId('radio_group') + .addOptions(fiveRadioOptions.map((option) => option.toJSON())) + .toJSON(), + ).not.toThrowError(); + }); + + test('Invalid builder does throw.', () => { + expect(() => new RadioGroupBuilder().toJSON()).toThrowError(); + expect(() => new RadioGroupBuilder().addOptions([]).toJSON()).toThrowError(); + // needs at least 2 options + expect(() => new RadioGroupBuilder().addOptions([fiveRadioOptions[0]]).toJSON()).toThrowError(); + expect(() => + new RadioGroupBuilder().setCustomId('radio_group').setOptions([fiveRadioOptions[0]]).toJSON(), + ).toThrowError(); + expect(() => new RadioGroupBuilder().setCustomId('radio_group').setOptions([]).toJSON()).toThrowError(); + expect(() => + new RadioGroupBuilder().setCustomId('radio_group').setOptions(elevenRadioOptions).toJSON(), + ).toThrowError(); + expect(() => new RadioGroupBuilder().setCustomId(longStr).setOptions(fiveRadioOptions).toJSON()).toThrowError(); + expect(() => + new RadioGroupBuilder() + .setCustomId('radio_group') + .setOptions([ + new RadioGroupOptionBuilder().setLabel('Option 1').setValue('option_1').setDefault(true), + new RadioGroupOptionBuilder().setLabel('Option 2').setValue('option_2').setDefault(true), + ]) + .toJSON(), + ).toThrowError(); + expect(() => + new RadioGroupBuilder() + .setCustomId('radio_group') + .addOptions(fiveRadioOptions) + .addOptions(fiveRadioOptions) + .toJSON(), + ).toThrowError(); + expect(() => + new RadioGroupBuilder() + .setCustomId('radio_group') + .setOptions(fiveRadioOptions) + .spliceOptions(2, 1, { value: 'hi', label: longStr }), + ).toThrowError(); + expect(() => + new RadioGroupBuilder().setCustomId('radio_group').addOptions({ value: 'hi', label: longStr }), + ).toThrowError(); + }); + }); + + describe('RadioGroupOptionBuilder', () => { + test('Valid builder does not throw.', () => { + expect(() => new RadioGroupOptionBuilder().setLabel('Option 1').setValue('option_1').toJSON()).not.toThrowError(); + expect(() => + new RadioGroupOptionBuilder() + .setLabel('Option 2') + .setValue('option_2') + .setDescription('This is option 2') + .toJSON(), + ).not.toThrowError(); + expect(() => + new RadioGroupOptionBuilder().setLabel('Option 3').setValue('option_3').setDefault(true).toJSON(), + ).not.toThrowError(); + }); + + test('Invalid builder does throw.', () => { + expect(() => new RadioGroupOptionBuilder().toJSON()).toThrowError(); + expect(() => new RadioGroupOptionBuilder().setValue('option_1').toJSON()).toThrowError(); + expect(() => new RadioGroupOptionBuilder().setLabel('Option 1').toJSON()).toThrowError(); + expect(() => new RadioGroupOptionBuilder().setLabel(longStr).setValue('option_1').toJSON()).toThrowError(); + expect(() => new RadioGroupOptionBuilder().setLabel('Option 1').setValue(longStr).toJSON()).toThrowError(); + }); + + test('toJSON returns correct data.', () => { + const option = new RadioGroupOptionBuilder() + .setLabel('Option 1') + .setValue('option_1') + .setDescription('This is option 1') + .setDefault(true); + + expect(option.toJSON()).toEqual({ + label: 'Option 1', + value: 'option_1', + description: 'This is option 1', + default: true, + }); + }); + }); +}); + +describe('LabelBuilder with Checkbox Components', () => { + test('LabelBuilder can set Checkbox component.', () => { + const checkbox = new CheckboxBuilder().setCustomId('checkbox').setDefault(true); + const label = new LabelBuilder().setLabel('Checkbox Label').setCheckboxComponent(checkbox); + expect(() => label.toJSON()).not.toThrowError(); + expect(label.toJSON().component).toEqual(checkbox.toJSON()); + }); + + test('LabelBuilder can set CheckboxGroup component.', () => { + const checkboxGroup = new CheckboxGroupBuilder().setCustomId('checkbox_group').setOptions(fiveCheckboxOptions); + const label = new LabelBuilder().setLabel('Checkbox Group Label').setCheckboxGroupComponent(checkboxGroup); + expect(() => label.toJSON()).not.toThrowError(); + expect(label.toJSON().component).toEqual(checkboxGroup.toJSON()); + }); + + test('LabelBuilder can set RadioGroup component.', () => { + const radioGroup = new RadioGroupBuilder().setCustomId('radio_group').setOptions(fiveRadioOptions); + const label = new LabelBuilder().setLabel('Radio Group Label').setRadioGroupComponent(radioGroup); + expect(() => label.toJSON()).not.toThrowError(); + expect(label.toJSON().component).toEqual(radioGroup.toJSON()); + }); +}); diff --git a/packages/builders/src/components/Components.ts b/packages/builders/src/components/Components.ts index 96bbcedda..17eed3d18 100644 --- a/packages/builders/src/components/Components.ts +++ b/packages/builders/src/components/Components.ts @@ -8,6 +8,9 @@ import { } from './ActionRow.js'; import { ComponentBuilder } from './Component.js'; import { ButtonBuilder } from './button/Button.js'; +import { CheckboxBuilder } from './checkbox/Checkbox.js'; +import { CheckboxGroupBuilder } from './checkbox/CheckboxGroup.js'; +import { RadioGroupBuilder } from './checkbox/RadioGroup.js'; import { FileUploadBuilder } from './fileUpload/FileUpload.js'; import { LabelBuilder } from './label/Label.js'; import { ChannelSelectMenuBuilder } from './selectMenu/ChannelSelectMenu.js'; @@ -110,6 +113,18 @@ export interface MappedComponentTypes { * The file upload component type is associated with a {@link FileUploadBuilder}. */ [ComponentType.FileUpload]: FileUploadBuilder; + /** + * The checkbox component type is associated with a {@link CheckboxBuilder}. + */ + [ComponentType.Checkbox]: CheckboxBuilder; + /** + * The checkbox group component type is associated with a {@link CheckboxGroupBuilder}. + */ + [ComponentType.CheckboxGroup]: CheckboxGroupBuilder; + /** + * The radio group component type is associated with a {@link RadioGroupBuilder}. + */ + [ComponentType.RadioGroup]: RadioGroupBuilder; } /** @@ -175,8 +190,14 @@ export function createComponentBuilder( return new LabelBuilder(data); case ComponentType.FileUpload: return new FileUploadBuilder(data); + case ComponentType.Checkbox: + return new CheckboxBuilder(data); + case ComponentType.CheckboxGroup: + return new CheckboxGroupBuilder(data); + case ComponentType.RadioGroup: + return new RadioGroupBuilder(data); default: - // https://github.com/discordjs/discord.js/pull/11410 + // @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/checkbox/Assertions.ts b/packages/builders/src/components/checkbox/Assertions.ts new file mode 100644 index 000000000..1eb88a670 --- /dev/null +++ b/packages/builders/src/components/checkbox/Assertions.ts @@ -0,0 +1,98 @@ +import { Result, s } from '@sapphire/shapeshift'; +import { ComponentType } from 'discord-api-types/v10'; +import { isValidationEnabled } from '../../util/validation'; +import { customIdValidator, idValidator } from '../Assertions'; + +export const checkboxPredicate = s + .object({ + type: s.literal(ComponentType.Checkbox), + custom_id: customIdValidator, + id: idValidator.optional(), + default: s.boolean().optional(), + }) + .setValidationEnabled(isValidationEnabled); + +export const checkboxGroupOptionPredicate = s + .object({ + label: s.string().lengthGreaterThanOrEqual(1).lengthLessThanOrEqual(100), + value: s.string().lengthGreaterThanOrEqual(1).lengthLessThanOrEqual(100), + description: s.string().lengthLessThanOrEqual(100).optional(), + default: s.boolean().optional(), + }) + .setValidationEnabled(isValidationEnabled); + +export const checkboxGroupPredicate = s + .object({ + type: s.literal(ComponentType.CheckboxGroup), + custom_id: customIdValidator, + id: idValidator.optional(), + options: s.array(checkboxGroupOptionPredicate).lengthGreaterThanOrEqual(1).lengthLessThanOrEqual(10), + min_values: s.number().int().greaterThanOrEqual(0).lessThanOrEqual(10).optional(), + max_values: s.number().int().greaterThanOrEqual(1).lessThanOrEqual(10).optional(), + required: s.boolean().optional(), + }) + .reshape((data) => { + // Ensure min_values is not greater than max_values + if (data.min_values !== undefined && data.max_values !== undefined && data.min_values > data.max_values) { + return Result.err(new RangeError('min_values cannot be greater than max_values')); + } + + // Ensure max_values is not greater than the number of options + if (data.max_values !== undefined && data.max_values > data.options.length) { + return Result.err(new RangeError('max_values cannot be greater than the number of options')); + } + + // Ensure min_values is not greater than the number of options + if (data.min_values !== undefined && data.min_values > data.options.length) { + return Result.err(new RangeError('min_values cannot be greater than the number of options')); + } + + // Ensure required is consistent with min_values + if (data.required === true && data.min_values === 0) { + return Result.err(new RangeError('If required is true, min_values must be at least 1')); + } + + // Ensure there are not more default values than max_values + const defaultCount = data.options.filter((option) => option.default === true).length; + if (data.max_values !== undefined && defaultCount > data.max_values) { + return Result.err(new RangeError('The number of default options cannot be greater than max_values')); + } + + // Ensure each option's value is unique + const values = data.options.map((option) => option.value); + const uniqueValues = new Set(values); + if (uniqueValues.size !== values.length) { + return Result.err(new RangeError('Each option in a checkbox group must have a unique value')); + } + + return Result.ok(data); + }) + .setValidationEnabled(isValidationEnabled); + +export const radioGroupOptionPredicate = checkboxGroupOptionPredicate; + +export const radioGroupPredicate = s + .object({ + type: s.literal(ComponentType.RadioGroup), + custom_id: customIdValidator, + id: idValidator.optional(), + options: s.array(radioGroupOptionPredicate).lengthGreaterThanOrEqual(2).lengthLessThanOrEqual(10), + required: s.boolean().optional(), + }) + .reshape((data) => { + // Ensure there is exactly one default option + const defaultCount = data.options.filter((option) => option.default === true).length; + if (defaultCount > 1) { + return Result.err(new RangeError('There can be at most one default option in a radio group')); + } + + // Ensure each option's value is unique + const values = data.options.map((option) => option.value); + const uniqueValues = new Set(values); + if (uniqueValues.size !== values.length) { + return Result.err(new RangeError('Each option in a radio group must have a unique value')); + } + + return Result.ok(data); + }) + .setValidationEnabled(isValidationEnabled); diff --git a/packages/builders/src/components/checkbox/Checkbox.ts b/packages/builders/src/components/checkbox/Checkbox.ts new file mode 100644 index 000000000..19b8a5d4a --- /dev/null +++ b/packages/builders/src/components/checkbox/Checkbox.ts @@ -0,0 +1,63 @@ +import type { APICheckboxComponent } from 'discord-api-types/v10'; +import { ComponentType } from 'discord-api-types/v10'; +import { ComponentBuilder } from '../Component'; +import { checkboxPredicate } from './Assertions'; + +/** + * A builder that creates API-compatible JSON data for checkboxes. + */ +export class CheckboxBuilder extends ComponentBuilder { + /** + * Creates a new checkbox from API data. + * + * @param data - The API data to create this checkbox with + * @example + * Creating a checkbox from an API data object: + * ```ts + * const checkbox = new CheckboxBuilder({ + * custom_id: 'accept_terms', + * default: false, + * }); + * ``` + * @example + * Creating a checkbox using setters and API data: + * ```ts + * const checkbox = new CheckboxBuilder() + * .setCustomId('subscribe_newsletter') + * .setDefault(true); + * ``` + */ + public constructor(data?: Partial) { + super({ type: ComponentType.Checkbox, ...data }); + } + + /** + * Sets the custom id of this checkbox. + * + * @param customId - The custom id to use + */ + public setCustomId(customId: string) { + this.data.custom_id = customId; + return this; + } + + /** + * Sets whether this checkbox is checked by default. + * + * @param isDefault - Whether the checkbox should be checked by default + */ + public setDefault(isDefault: boolean) { + this.data.default = isDefault; + return this; + } + + /** + * {@inheritDoc ComponentBuilder.toJSON} + */ + public override toJSON(): APICheckboxComponent { + checkboxPredicate.parse(this.data); + return { + ...this.data, + } as APICheckboxComponent; + } +} diff --git a/packages/builders/src/components/checkbox/CheckboxGroup.ts b/packages/builders/src/components/checkbox/CheckboxGroup.ts new file mode 100644 index 000000000..6833c3e6a --- /dev/null +++ b/packages/builders/src/components/checkbox/CheckboxGroup.ts @@ -0,0 +1,174 @@ +import type { APICheckboxGroupComponent, APICheckboxGroupOption } from 'discord-api-types/v10'; +import { ComponentType } from 'discord-api-types/v10'; +import type { RestOrArray } from '../../util/normalizeArray'; +import { normalizeArray } from '../../util/normalizeArray'; +import { ComponentBuilder } from '../Component'; +import { checkboxGroupOptionPredicate, checkboxGroupPredicate } from './Assertions'; +import { CheckboxGroupOptionBuilder } from './CheckboxGroupOption'; + +/** + * A builder that creates API-compatible JSON data for checkbox groups. + */ +export class CheckboxGroupBuilder extends ComponentBuilder { + /** + * The options within this checkbox group. + */ + public readonly options: CheckboxGroupOptionBuilder[]; + + /** + * Creates a new checkbox group from API data. + * + * @param data - The API data to create this checkbox group with + * @example + * Creating a checkbox group from an API data object: + * ```ts + * const checkboxGroup = new CheckboxGroupBuilder({ + * custom_id: 'select_options', + * options: [ + * { label: 'Option 1', value: 'option_1' }, + * { label: 'Option 2', value: 'option_2' }, + * ], + * }); + * ``` + * @example + * Creating a checkbox group using setters and API data: + * ```ts + * const checkboxGroup = new CheckboxGroupBuilder() + * .setCustomId('choose_items') + * .setOptions([ + * { label: 'Item A', value: 'item_a' }, + * { label: 'Item B', value: 'item_b' }, + * ]) + * .setMinValues(1) + * .setMaxValues(2); + * ``` + */ + public constructor(data?: Partial) { + const { options, ...initData } = data ?? {}; + super({ ...initData, type: ComponentType.CheckboxGroup }); + this.options = options?.map((option: APICheckboxGroupOption) => new CheckboxGroupOptionBuilder(option)) ?? []; + } + + /** + * Sets the custom id of this checkbox group. + * + * @param customId - The custom id to use + */ + public setCustomId(customId: string) { + this.data.custom_id = customId; + return this; + } + + /** + * Adds options to this checkbox group. + * + * @param options - The options to add + */ + public addOptions(...options: RestOrArray) { + const normalizedOptions = normalizeArray(options); + + this.options.push( + ...normalizedOptions.map((normalizedOption) => { + // I do this because TS' duck typing causes issues, + // if I put in a RadioGroupOption, TS lets it pass but + // it fails to convert to a checkbox group option at runtime + const json = 'toJSON' in normalizedOption ? normalizedOption.toJSON() : normalizedOption; + const option = new CheckboxGroupOptionBuilder(json); + checkboxGroupOptionPredicate.parse(option.toJSON()); + return option; + }), + ); + return this; + } + + /** + * Sets the options for this checkbox group. + * + * @param options - The options to use + */ + public setOptions(options: RestOrArray) { + return this.spliceOptions(0, this.options.length, ...options); + } + + /** + * Removes, replaces, or inserts options for this checkbox group. + * + * @remarks + * This method behaves similarly + * to {@link https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array/splice | Array.prototype.splice()}. + * It's useful for modifying and adjusting the order of existing options. + * @param index - The index to start at + * @param deleteCount - The number of options to remove + * @param options - The replacing option objects or builders + */ + public spliceOptions( + index: number, + deleteCount: number, + ...options: RestOrArray + ) { + const normalizedOptions = normalizeArray(options); + + const clone = [...this.options]; + + clone.splice( + index, + deleteCount, + ...normalizedOptions.map((normalizedOption) => { + // I do this because TS' duck typing causes issues, + // if I put in a RadioGroupOption, TS lets it pass but + // it fails to convert to a checkbox group option at runtime + const json = 'toJSON' in normalizedOption ? normalizedOption.toJSON() : normalizedOption; + const option = new CheckboxGroupOptionBuilder(json); + checkboxGroupOptionPredicate.parse(option.toJSON()); + return option; + }), + ); + + this.options.splice(0, this.options.length, ...clone); + return this; + } + + /** + * Sets the minimum number of options that must be selected. + * + * @param minValues - The minimum number of options that must be selected + */ + public setMinValues(minValues: number) { + this.data.min_values = minValues; + return this; + } + + /** + * Sets the maximum number of options that can be selected. + * + * @param maxValues - The maximum number of options that can be selected + */ + public setMaxValues(maxValues: number) { + this.data.max_values = maxValues; + return this; + } + + /** + * Sets whether selecting options is required. + * + * @param required - Whether selecting options is required + */ + public setRequired(required: boolean) { + this.data.required = required; + return this; + } + + /** + * {@inheritDoc ComponentBuilder.toJSON} + */ + public override toJSON(): APICheckboxGroupComponent { + const data = { + ...this.data, + options: this.options.map((option) => option.toJSON()), + }; + + checkboxGroupPredicate.parse(data); + + return data as APICheckboxGroupComponent; + } +} diff --git a/packages/builders/src/components/checkbox/CheckboxGroupOption.ts b/packages/builders/src/components/checkbox/CheckboxGroupOption.ts new file mode 100644 index 000000000..e1fa5637e --- /dev/null +++ b/packages/builders/src/components/checkbox/CheckboxGroupOption.ts @@ -0,0 +1,81 @@ +import type { JSONEncodable } from '@discordjs/util'; +import type { APICheckboxGroupOption } from 'discord-api-types/v10'; +import { checkboxGroupOptionPredicate } from './Assertions'; + +/** + * A builder that creates API-compatible JSON data for checkbox group options. + */ +export class CheckboxGroupOptionBuilder implements JSONEncodable { + /** + * Creates a new checkbox group option from API data. + * + * @param data - The API data to create this checkbox group option with + * @example + * Creating a checkbox group option from an API data object: + * ```ts + * const option = new CheckboxGroupOptionBuilder({ + * label: 'Option 1', + * value: 'option_1', + * }); + * ``` + * @example + * Creating a checkbox group option using setters and API data: + * ```ts + * const option = new CheckboxGroupOptionBuilder() + * .setLabel('Option 2') + * .setValue('option_2'); + * ``` + */ + public constructor(public data: Partial = {}) {} + + /** + * Sets the label for this option. + * + * @param label - The label to use + */ + public setLabel(label: string) { + this.data.label = label; + return this; + } + + /** + * Sets the value for this option. + * + * @param value - The value to use + */ + public setValue(value: string) { + this.data.value = value; + return this; + } + + /** + * Sets the description for this option. + * + * @param description - The description to use + */ + public setDescription(description: string) { + this.data.description = description; + return this; + } + + /** + * Sets whether this option is selected by default. + * + * @param isDefault - Whether the option should be selected by default + */ + public setDefault(isDefault: boolean) { + this.data.default = isDefault; + return this; + } + + /** + * {@inheritDoc ComponentBuilder.toJSON} + */ + public toJSON(): APICheckboxGroupOption { + checkboxGroupOptionPredicate.parse(this.data); + + return { + ...this.data, + } as APICheckboxGroupOption; + } +} diff --git a/packages/builders/src/components/checkbox/RadioGroup.ts b/packages/builders/src/components/checkbox/RadioGroup.ts new file mode 100644 index 000000000..e3407d5ac --- /dev/null +++ b/packages/builders/src/components/checkbox/RadioGroup.ts @@ -0,0 +1,152 @@ +import type { APIRadioGroupComponent, APIRadioGroupOption } from 'discord-api-types/v10'; +import { ComponentType } from 'discord-api-types/v10'; +import type { RestOrArray } from '../../util/normalizeArray'; +import { normalizeArray } from '../../util/normalizeArray'; +import { ComponentBuilder } from '../Component'; +import { radioGroupOptionPredicate, radioGroupPredicate } from './Assertions'; +import { RadioGroupOptionBuilder } from './RadioGroupOption'; + +/** + * A builder that creates API-compatible JSON data for radio groups. + */ +export class RadioGroupBuilder extends ComponentBuilder { + /** + * The options within this radio group. + */ + public readonly options: RadioGroupOptionBuilder[]; + + /** + * Creates a new radio group from API data. + * + * @param data - The API data to create this radio group with + * @example + * Creating a radio group from an API data object: + * ```ts + * const radioGroup = new RadioGroupBuilder({ + * custom_id: 'select_options', + * options: [ + * { label: 'Option 1', value: 'option_1' }, + * { label: 'Option 2', value: 'option_2' }, + * ], + * }); + * ``` + * @example + * Creating a radio group using setters and API data: + * ```ts + * const radioGroup = new RadioGroupBuilder() + * .setCustomId('choose_items') + * .setOptions([ + * { label: 'Item A', value: 'item_a' }, + * { label: 'Item B', value: 'item_b' }, + * ]) + * ``` + */ + public constructor(data?: Partial) { + const { options, ...initData } = data ?? {}; + super({ ...initData, type: ComponentType.RadioGroup }); + this.options = options?.map((option: APIRadioGroupOption) => new RadioGroupOptionBuilder(option)) ?? []; + } + + /** + * Sets the custom id of this radio group. + * + * @param customId - The custom id to use + */ + public setCustomId(customId: string) { + this.data.custom_id = customId; + return this; + } + + /** + * Adds options to this radio group. + * + * @param options - The options to add + */ + public addOptions(...options: RestOrArray) { + const normalizedOptions = normalizeArray(options); + + this.options.push( + ...normalizedOptions.map((normalizedOption) => { + // I do this because TS' duck typing causes issues, + // if I put in a CheckboxGroupOption, TS lets it pass but + // it fails to convert to a checkbox group option at runtime + const json = 'toJSON' in normalizedOption ? normalizedOption.toJSON() : normalizedOption; + const option = new RadioGroupOptionBuilder(json); + radioGroupOptionPredicate.parse(option.toJSON()); + return option; + }), + ); + return this; + } + + /** + * Sets the options for this radio group. + * + * @param options - The options to use + */ + public setOptions(options: RestOrArray) { + return this.spliceOptions(0, this.options.length, ...options); + } + + /** + * Removes, replaces, or inserts options for this radio group. + * + * @remarks + * This method behaves similarly + * to {@link https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array/splice | Array.prototype.splice()}. + * It's useful for modifying and adjusting the order of existing options. + * @param index - The index to start at + * @param deleteCount - The number of options to remove + * @param options - The replacing option objects or builders + */ + public spliceOptions( + index: number, + deleteCount: number, + ...options: RestOrArray + ) { + const normalizedOptions = normalizeArray(options); + + const clone = [...this.options]; + + clone.splice( + index, + deleteCount, + ...normalizedOptions.map((normalizedOption) => { + // I do this because TS' duck typing causes issues, + // if I put in a CheckboxGroupOption, TS lets it pass but + // it fails to convert to a radio group option at runtime + const json = 'toJSON' in normalizedOption ? normalizedOption.toJSON() : normalizedOption; + const option = new RadioGroupOptionBuilder(json); + radioGroupOptionPredicate.parse(option.toJSON()); + return option; + }), + ); + + this.options.splice(0, this.options.length, ...clone); + return this; + } + + /** + * Sets whether selecting options is required. + * + * @param required - Whether selecting options is required + */ + public setRequired(required: boolean) { + this.data.required = required; + return this; + } + + /** + * {@inheritDoc ComponentBuilder.toJSON} + */ + public override toJSON(): APIRadioGroupComponent { + const data = { + ...this.data, + options: this.options.map((option) => option.toJSON()), + }; + + radioGroupPredicate.parse(data); + + return data as APIRadioGroupComponent; + } +} diff --git a/packages/builders/src/components/checkbox/RadioGroupOption.ts b/packages/builders/src/components/checkbox/RadioGroupOption.ts new file mode 100644 index 000000000..905d0c95c --- /dev/null +++ b/packages/builders/src/components/checkbox/RadioGroupOption.ts @@ -0,0 +1,81 @@ +import type { JSONEncodable } from '@discordjs/util'; +import type { APIRadioGroupOption } from 'discord-api-types/v10'; +import { radioGroupOptionPredicate } from './Assertions'; + +/** + * A builder that creates API-compatible JSON data for radio group options. + */ +export class RadioGroupOptionBuilder implements JSONEncodable { + /** + * Creates a new radio group option from API data. + * + * @param data - The API data to create this radio group option with + * @example + * Creating a radio group option from an API data object: + * ```ts + * const option = new RadioGroupOptionBuilder({ + * label: 'Option 1', + * value: 'option_1', + * }); + * ``` + * @example + * Creating a radio group option using setters and API data: + * ```ts + * const option = new RadioGroupOptionBuilder() + * .setLabel('Option 2') + * .setValue('option_2'); + * ``` + */ + public constructor(public data: Partial = {}) {} + + /** + * Sets the label for this option. + * + * @param label - The label to use + */ + public setLabel(label: string) { + this.data.label = label; + return this; + } + + /** + * Sets the value for this option. + * + * @param value - The value to use + */ + public setValue(value: string) { + this.data.value = value; + return this; + } + + /** + * Sets the description for this option. + * + * @param description - The description to use + */ + public setDescription(description: string) { + this.data.description = description; + return this; + } + + /** + * Sets whether this option is selected by default. + * + * @param isDefault - Whether the option should be selected by default + */ + public setDefault(isDefault: boolean) { + this.data.default = isDefault; + return this; + } + + /** + * {@inheritDoc ComponentBuilder.toJSON} + */ + public toJSON(): APIRadioGroupOption { + radioGroupOptionPredicate.parse(this.data); + + return { + ...this.data, + } as APIRadioGroupOption; + } +} diff --git a/packages/builders/src/components/label/Assertions.ts b/packages/builders/src/components/label/Assertions.ts index 8e17ca4c3..2ef3f3ddf 100644 --- a/packages/builders/src/components/label/Assertions.ts +++ b/packages/builders/src/components/label/Assertions.ts @@ -2,6 +2,7 @@ import { s } from '@sapphire/shapeshift'; import { ComponentType } from 'discord-api-types/v10'; import { isValidationEnabled } from '../../util/validation.js'; import { idValidator } from '../Assertions.js'; +import { checkboxGroupPredicate, checkboxPredicate, radioGroupPredicate } from '../checkbox/Assertions.js'; import { fileUploadPredicate } from '../fileUpload/Assertions.js'; import { selectMenuChannelPredicate, @@ -26,6 +27,9 @@ export const labelPredicate = s selectMenuChannelPredicate, selectMenuStringPredicate, fileUploadPredicate, + checkboxPredicate, + checkboxGroupPredicate, + radioGroupPredicate, ]), }) .setValidationEnabled(isValidationEnabled); diff --git a/packages/builders/src/components/label/Label.ts b/packages/builders/src/components/label/Label.ts index 8da82c3b7..fd5feb608 100644 --- a/packages/builders/src/components/label/Label.ts +++ b/packages/builders/src/components/label/Label.ts @@ -1,8 +1,11 @@ import type { APIChannelSelectComponent, + APICheckboxComponent, + APICheckboxGroupComponent, APIFileUploadComponent, APILabelComponent, APIMentionableSelectComponent, + APIRadioGroupComponent, APIRoleSelectComponent, APIStringSelectComponent, APITextInputComponent, @@ -11,6 +14,9 @@ import type { import { ComponentType } from 'discord-api-types/v10'; import { ComponentBuilder } from '../Component.js'; import { createComponentBuilder, resolveBuilder } from '../Components.js'; +import { CheckboxBuilder } from '../checkbox/Checkbox.js'; +import { CheckboxGroupBuilder } from '../checkbox/CheckboxGroup.js'; +import { RadioGroupBuilder } from '../checkbox/RadioGroup.js'; import { FileUploadBuilder } from '../fileUpload/FileUpload.js'; import { ChannelSelectMenuBuilder } from '../selectMenu/ChannelSelectMenu.js'; import { MentionableSelectMenuBuilder } from '../selectMenu/MentionableSelectMenu.js'; @@ -23,8 +29,11 @@ import { labelPredicate } from './Assertions.js'; export interface LabelBuilderData extends Partial> { component?: | ChannelSelectMenuBuilder + | CheckboxBuilder + | CheckboxGroupBuilder | FileUploadBuilder | MentionableSelectMenuBuilder + | RadioGroupBuilder | RoleSelectMenuBuilder | StringSelectMenuBuilder | TextInputBuilder @@ -68,7 +77,6 @@ export class LabelBuilder extends ComponentBuilder { this.data = { ...rest, - // @ts-expect-error https://github.com/discordjs/discord.js/pull/11410 component: component ? createComponentBuilder(component) : undefined, type: ComponentType.Label, }; @@ -195,6 +203,42 @@ export class LabelBuilder extends ComponentBuilder { return this; } + /** + * Sets a checkbox component to this label. + * + * @param input - A function that returns a component builder or an already built builder + */ + public setCheckboxComponent( + input: APICheckboxComponent | CheckboxBuilder | ((builder: CheckboxBuilder) => CheckboxBuilder), + ): this { + this.data.component = resolveBuilder(input, CheckboxBuilder); + return this; + } + + /** + * Sets a checkbox group component to this label. + * + * @param input - A function that returns a component builder or an already built builder + */ + public setCheckboxGroupComponent( + input: APICheckboxGroupComponent | CheckboxGroupBuilder | ((builder: CheckboxGroupBuilder) => CheckboxGroupBuilder), + ): this { + this.data.component = resolveBuilder(input, CheckboxGroupBuilder); + return this; + } + + /** + * Sets a radio group component to this label. + * + * @param input - A function that returns a component builder or an already built builder + */ + public setRadioGroupComponent( + input: APIRadioGroupComponent | RadioGroupBuilder | ((builder: RadioGroupBuilder) => RadioGroupBuilder), + ): this { + this.data.component = resolveBuilder(input, RadioGroupBuilder); + return this; + } + /** * {@inheritDoc ComponentBuilder.toJSON} */ diff --git a/packages/builders/src/index.ts b/packages/builders/src/index.ts index 77f8fc792..d4733e8ee 100644 --- a/packages/builders/src/index.ts +++ b/packages/builders/src/index.ts @@ -51,6 +51,13 @@ export * from './components/v2/Separator.js'; export * from './components/v2/TextDisplay.js'; export * from './components/v2/Thumbnail.js'; +export * from './components/checkbox/Checkbox.js'; +export * from './components/checkbox/CheckboxGroup.js'; +export * from './components/checkbox/CheckboxGroupOption.js'; +export * from './components/checkbox/RadioGroup.js'; +export * from './components/checkbox/RadioGroupOption.js'; +export * as CheckboxAssertions from './components/checkbox/Assertions.js'; + export * as SlashCommandAssertions from './interactions/slashCommands/Assertions.js'; export * from './interactions/slashCommands/SlashCommandBuilder.js'; export * from './interactions/slashCommands/SlashCommandSubcommands.js';