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
415 changes: 415 additions & 0 deletions packages/builders/__tests__/components/checkbox.test.ts

Large diffs are not rendered by default.

21 changes: 21 additions & 0 deletions packages/builders/src/components/Components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -175,6 +190,12 @@ 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:
// @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
112 changes: 112 additions & 0 deletions packages/builders/src/components/checkbox/Assertions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
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) {
throw 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) {
throw 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) {
throw 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) {
throw 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) {
throw 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) {
throw 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) {
throw new RangeError('There must be exactly 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) {
throw new RangeError('Each option in a radio group must have a unique value');
}

return Result.ok(data);
})
.setValidationEnabled(isValidationEnabled);

export const checkboxGroupOptionsLengthValidator = s
.number()
.int()
.greaterThanOrEqual(1)
.lessThanOrEqual(10)
.setValidationEnabled(isValidationEnabled);

export const radioGroupOptionsLengthValidator = s
.number()
.int()
.greaterThanOrEqual(2)
.lessThanOrEqual(10)
.setValidationEnabled(isValidationEnabled);
63 changes: 63 additions & 0 deletions packages/builders/src/components/checkbox/Checkbox.ts
Original file line number Diff line number Diff line change
@@ -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<APICheckboxComponent> {
/**
* 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<APICheckboxComponent>) {
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;
}
}
180 changes: 180 additions & 0 deletions packages/builders/src/components/checkbox/CheckboxGroup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
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,
checkboxGroupOptionsLengthValidator,
checkboxGroupPredicate,
} from './Assertions';
import { CheckboxGroupOptionBuilder } from './CheckboxGroupOption';

/**
* A builder that creates API-compatible JSON data for checkbox groups.
*/
export class CheckboxGroupBuilder extends ComponentBuilder<APICheckboxGroupComponent> {
/**
* 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<APICheckboxGroupComponent>) {
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<APICheckboxGroupOption | CheckboxGroupOptionBuilder>) {
const normalizedOptions = normalizeArray(options);

checkboxGroupOptionsLengthValidator.parse(this.options.length + normalizedOptions.length);
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<APICheckboxGroupOption | CheckboxGroupOptionBuilder>) {
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/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/slice | 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<APICheckboxGroupOption | CheckboxGroupOptionBuilder>
) {
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;
}),
);

checkboxGroupOptionsLengthValidator.parse(clone.length);
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;
}
}
Loading
Loading