Skip to content

Commit 4d0e88d

Browse files
committed
feat(builders): add checkbox, checkboxgroup, and radiogroup builders
1 parent 4476fad commit 4d0e88d

File tree

11 files changed

+1163
-0
lines changed

11 files changed

+1163
-0
lines changed

packages/builders/__tests__/components/checkbox.test.ts

Lines changed: 415 additions & 0 deletions
Large diffs are not rendered by default.

packages/builders/src/components/Components.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ import {
88
} from './ActionRow.js';
99
import { ComponentBuilder } from './Component.js';
1010
import { ButtonBuilder } from './button/Button.js';
11+
import { CheckboxBuilder } from './checkbox/Checkbox.js';
12+
import { CheckboxGroupBuilder } from './checkbox/CheckboxGroup.js';
13+
import { RadioGroupBuilder } from './checkbox/RadioGroup.js';
1114
import { FileUploadBuilder } from './fileUpload/FileUpload.js';
1215
import { LabelBuilder } from './label/Label.js';
1316
import { ChannelSelectMenuBuilder } from './selectMenu/ChannelSelectMenu.js';
@@ -110,6 +113,18 @@ export interface MappedComponentTypes {
110113
* The file upload component type is associated with a {@link FileUploadBuilder}.
111114
*/
112115
[ComponentType.FileUpload]: FileUploadBuilder;
116+
/**
117+
* The checkbox component type is associated with a {@link CheckboxBuilder}.
118+
*/
119+
[ComponentType.Checkbox]: CheckboxBuilder;
120+
/**
121+
* The checkbox group component type is associated with a {@link CheckboxGroupBuilder}.
122+
*/
123+
[ComponentType.CheckboxGroup]: CheckboxGroupBuilder;
124+
/**
125+
* The radio group component type is associated with a {@link RadioGroupBuilder}.
126+
*/
127+
[ComponentType.RadioGroup]: RadioGroupBuilder;
113128
}
114129

115130
/**
@@ -175,6 +190,12 @@ export function createComponentBuilder(
175190
return new LabelBuilder(data);
176191
case ComponentType.FileUpload:
177192
return new FileUploadBuilder(data);
193+
case ComponentType.Checkbox:
194+
return new CheckboxBuilder(data);
195+
case ComponentType.CheckboxGroup:
196+
return new CheckboxGroupBuilder(data);
197+
case ComponentType.RadioGroup:
198+
return new RadioGroupBuilder(data);
178199
default:
179200
// @ts-expect-error This case can still occur if we get a newer unsupported component type
180201
throw new Error(`Cannot properly serialize component type: ${data.type}`);
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { Result, s } from '@sapphire/shapeshift';
2+
import { ComponentType } from 'discord-api-types/v10';
3+
import { isValidationEnabled } from '../../util/validation';
4+
import { customIdValidator, idValidator } from '../Assertions';
5+
6+
export const checkboxPredicate = s
7+
.object({
8+
type: s.literal(ComponentType.Checkbox),
9+
custom_id: customIdValidator,
10+
id: idValidator.optional(),
11+
default: s.boolean().optional(),
12+
})
13+
.setValidationEnabled(isValidationEnabled);
14+
15+
export const checkboxGroupOptionPredicate = s
16+
.object({
17+
label: s.string().lengthGreaterThanOrEqual(1).lengthLessThanOrEqual(100),
18+
value: s.string().lengthGreaterThanOrEqual(1).lengthLessThanOrEqual(100),
19+
description: s.string().lengthLessThanOrEqual(100).optional(),
20+
default: s.boolean().optional(),
21+
})
22+
.setValidationEnabled(isValidationEnabled);
23+
24+
export const checkboxGroupPredicate = s
25+
.object({
26+
type: s.literal(ComponentType.CheckboxGroup),
27+
custom_id: customIdValidator,
28+
id: idValidator.optional(),
29+
options: s.array(checkboxGroupOptionPredicate).lengthGreaterThanOrEqual(1).lengthLessThanOrEqual(10),
30+
min_values: s.number().int().greaterThanOrEqual(0).lessThanOrEqual(10).optional(),
31+
max_values: s.number().int().greaterThanOrEqual(1).lessThanOrEqual(10).optional(),
32+
required: s.boolean().optional(),
33+
})
34+
.reshape((data) => {
35+
// Ensure min_values is not greater than max_values
36+
if (data.min_values !== undefined && data.max_values !== undefined && data.min_values > data.max_values) {
37+
throw new RangeError('min_values cannot be greater than max_values');
38+
}
39+
40+
// Ensure max_values is not greater than the number of options
41+
if (data.max_values !== undefined && data.max_values > data.options.length) {
42+
throw new RangeError('max_values cannot be greater than the number of options');
43+
}
44+
45+
// Ensure min_values is not greater than the number of options
46+
if (data.min_values !== undefined && data.min_values > data.options.length) {
47+
throw new RangeError('min_values cannot be greater than the number of options');
48+
}
49+
50+
// Ensure required is consistent with min_values
51+
if (data.required === true && data.min_values === 0) {
52+
throw new RangeError('If required is true, min_values must be at least 1');
53+
}
54+
55+
// Ensure there are not more default values than max_values
56+
const defaultCount = data.options.filter((option) => option.default === true).length;
57+
if (data.max_values !== undefined && defaultCount > data.max_values) {
58+
throw new RangeError('The number of default options cannot be greater than max_values');
59+
}
60+
61+
// Ensure each option's value is unique
62+
const values = data.options.map((option) => option.value);
63+
const uniqueValues = new Set(values);
64+
if (uniqueValues.size !== values.length) {
65+
throw new RangeError('Each option in a checkbox group must have a unique value');
66+
}
67+
68+
return Result.ok(data);
69+
})
70+
.setValidationEnabled(isValidationEnabled);
71+
72+
export const radioGroupOptionPredicate = checkboxGroupOptionPredicate;
73+
74+
export const radioGroupPredicate = s
75+
.object({
76+
type: s.literal(ComponentType.RadioGroup),
77+
custom_id: customIdValidator,
78+
id: idValidator.optional(),
79+
options: s.array(radioGroupOptionPredicate).lengthGreaterThanOrEqual(2).lengthLessThanOrEqual(10),
80+
required: s.boolean().optional(),
81+
})
82+
.reshape((data) => {
83+
// Ensure there is exactly one default option
84+
const defaultCount = data.options.filter((option) => option.default === true).length;
85+
if (defaultCount > 1) {
86+
throw new RangeError('There must be exactly one default option in a radio group');
87+
}
88+
89+
// Ensure each option's value is unique
90+
const values = data.options.map((option) => option.value);
91+
const uniqueValues = new Set(values);
92+
if (uniqueValues.size !== values.length) {
93+
throw new RangeError('Each option in a radio group must have a unique value');
94+
}
95+
96+
return Result.ok(data);
97+
})
98+
.setValidationEnabled(isValidationEnabled);
99+
100+
export const checkboxGroupOptionsLengthValidator = s
101+
.number()
102+
.int()
103+
.greaterThanOrEqual(1)
104+
.lessThanOrEqual(10)
105+
.setValidationEnabled(isValidationEnabled);
106+
107+
export const radioGroupOptionsLengthValidator = s
108+
.number()
109+
.int()
110+
.greaterThanOrEqual(2)
111+
.lessThanOrEqual(10)
112+
.setValidationEnabled(isValidationEnabled);
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import type { APICheckboxComponent } from 'discord-api-types/v10';
2+
import { ComponentType } from 'discord-api-types/v10';
3+
import { ComponentBuilder } from '../Component';
4+
import { checkboxPredicate } from './Assertions';
5+
6+
/**
7+
* A builder that creates API-compatible JSON data for checkboxes.
8+
*/
9+
export class CheckboxBuilder extends ComponentBuilder<APICheckboxComponent> {
10+
/**
11+
* Creates a new checkbox from API data.
12+
*
13+
* @param data - The API data to create this checkbox with
14+
* @example
15+
* Creating a checkbox from an API data object:
16+
* ```ts
17+
* const checkbox = new CheckboxBuilder({
18+
* custom_id: 'accept_terms',
19+
* default: false,
20+
* });
21+
* ```
22+
* @example
23+
* Creating a checkbox using setters and API data:
24+
* ```ts
25+
* const checkbox = new CheckboxBuilder()
26+
* .setCustomId('subscribe_newsletter')
27+
* .setDefault(true);
28+
* ```
29+
*/
30+
public constructor(data?: Partial<APICheckboxComponent>) {
31+
super({ type: ComponentType.Checkbox, ...data });
32+
}
33+
34+
/**
35+
* Sets the custom ID of this checkbox.
36+
*
37+
* @param customId - The custom ID to use
38+
*/
39+
public setCustomId(customId: string) {
40+
this.data.custom_id = customId;
41+
return this;
42+
}
43+
44+
/**
45+
* Sets whether this checkbox is checked by default.
46+
*
47+
* @param isDefault - Whether the checkbox should be checked by default
48+
*/
49+
public setDefault(isDefault: boolean) {
50+
this.data.default = isDefault;
51+
return this;
52+
}
53+
54+
/**
55+
* {@inheritDoc ComponentBuilder.toJSON}
56+
*/
57+
public override toJSON(): APICheckboxComponent {
58+
checkboxPredicate.parse(this.data);
59+
return {
60+
...this.data,
61+
} as APICheckboxComponent;
62+
}
63+
}
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
import type { APICheckboxGroupComponent, APICheckboxGroupOption } from 'discord-api-types/v10';
2+
import { ComponentType } from 'discord-api-types/v10';
3+
import type { RestOrArray } from '../../util/normalizeArray';
4+
import { normalizeArray } from '../../util/normalizeArray';
5+
import { ComponentBuilder } from '../Component';
6+
import {
7+
checkboxGroupOptionPredicate,
8+
checkboxGroupOptionsLengthValidator,
9+
checkboxGroupPredicate,
10+
} from './Assertions';
11+
import { CheckboxGroupOptionBuilder } from './CheckboxGroupOption';
12+
13+
/**
14+
* A builder that creates API-compatible JSON data for checkbox groups.
15+
*/
16+
export class CheckboxGroupBuilder extends ComponentBuilder<APICheckboxGroupComponent> {
17+
/**
18+
* The options within this checkbox group.
19+
*/
20+
public readonly options: CheckboxGroupOptionBuilder[];
21+
22+
/**
23+
* Creates a new checkbox group from API data.
24+
*
25+
* @param data - The API data to create this checkbox group with
26+
* @example
27+
* Creating a checkbox group from an API data object:
28+
* ```ts
29+
* const checkboxGroup = new CheckboxGroupBuilder({
30+
* custom_id: 'select_options',
31+
* options: [
32+
* { label: 'Option 1', value: 'option_1' },
33+
* { label: 'Option 2', value: 'option_2' },
34+
* ],
35+
* });
36+
* ```
37+
* @example
38+
* Creating a checkbox group using setters and API data:
39+
* ```ts
40+
* const checkboxGroup = new CheckboxGroupBuilder()
41+
* .setCustomId('choose_items')
42+
* .setOptions([
43+
* { label: 'Item A', value: 'item_a' },
44+
* { label: 'Item B', value: 'item_b' },
45+
* ])
46+
* .setMinValues(1)
47+
* .setMaxValues(2);
48+
* ```
49+
*/
50+
public constructor(data?: Partial<APICheckboxGroupComponent>) {
51+
const { options, ...initData } = data ?? {};
52+
super({ ...initData, type: ComponentType.CheckboxGroup });
53+
this.options = options?.map((option: APICheckboxGroupOption) => new CheckboxGroupOptionBuilder(option)) ?? [];
54+
}
55+
56+
/**
57+
* Sets the custom ID of this checkbox group.
58+
*
59+
* @param customId - The custom ID to use
60+
*/
61+
public setCustomId(customId: string) {
62+
this.data.custom_id = customId;
63+
return this;
64+
}
65+
66+
/**
67+
* Adds options to this checkbox group.
68+
*
69+
* @param options - The options to add
70+
*/
71+
public addOptions(...options: RestOrArray<APICheckboxGroupOption | CheckboxGroupOptionBuilder>) {
72+
const normalizedOptions = normalizeArray(options);
73+
74+
checkboxGroupOptionsLengthValidator.parse(this.options.length + normalizedOptions.length);
75+
this.options.push(
76+
...normalizedOptions.map((normalizedOption) => {
77+
// I do this because TS' duck typing causes issues,
78+
// if I put in a RadioGroupOption, TS lets it pass but
79+
// it fails to convert to a checkbox group option at runtime
80+
const json = 'toJSON' in normalizedOption ? normalizedOption.toJSON() : normalizedOption;
81+
const option = new CheckboxGroupOptionBuilder(json);
82+
checkboxGroupOptionPredicate.parse(option.toJSON());
83+
return option;
84+
}),
85+
);
86+
return this;
87+
}
88+
89+
/**
90+
* Sets the options for this checkbox group.
91+
*
92+
* @param options - The options to use
93+
*/
94+
public setOptions(options: RestOrArray<APICheckboxGroupOption | CheckboxGroupOptionBuilder>) {
95+
return this.spliceOptions(0, this.options.length, ...options);
96+
}
97+
98+
/**
99+
* Removes, replaces, or inserts options for this checkbox group.
100+
*
101+
* @remarks
102+
* This method behaves similarly
103+
* to {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/slice | Array.prototype.splice()}.
104+
* It's useful for modifying and adjusting the order of existing options.
105+
* @param index - The index to start at
106+
* @param deleteCount - The number of options to remove
107+
* @param options - The replacing option objects or builders
108+
*/
109+
public spliceOptions(
110+
index: number,
111+
deleteCount: number,
112+
...options: RestOrArray<APICheckboxGroupOption | CheckboxGroupOptionBuilder>
113+
) {
114+
const normalizedOptions = normalizeArray(options);
115+
116+
const clone = [...this.options];
117+
118+
clone.splice(
119+
index,
120+
deleteCount,
121+
...normalizedOptions.map((normalizedOption) => {
122+
// I do this because TS' duck typing causes issues,
123+
// if I put in a RadioGroupOption, TS lets it pass but
124+
// it fails to convert to a checkbox group option at runtime
125+
const json = 'toJSON' in normalizedOption ? normalizedOption.toJSON() : normalizedOption;
126+
const option = new CheckboxGroupOptionBuilder(json);
127+
checkboxGroupOptionPredicate.parse(option.toJSON());
128+
return option;
129+
}),
130+
);
131+
132+
checkboxGroupOptionsLengthValidator.parse(clone.length);
133+
this.options.splice(0, this.options.length, ...clone);
134+
return this;
135+
}
136+
137+
/**
138+
* Sets the minimum number of options that must be selected.
139+
*
140+
* @param minValues - The minimum number of options that must be selected
141+
*/
142+
public setMinValues(minValues: number) {
143+
this.data.min_values = minValues;
144+
return this;
145+
}
146+
147+
/**
148+
* Sets the maximum number of options that can be selected.
149+
*
150+
* @param maxValues - The maximum number of options that can be selected
151+
*/
152+
public setMaxValues(maxValues: number) {
153+
this.data.max_values = maxValues;
154+
return this;
155+
}
156+
157+
/**
158+
* Sets whether selecting options is required.
159+
*
160+
* @param required - Whether selecting options is required
161+
*/
162+
public setRequired(required: boolean) {
163+
this.data.required = required;
164+
return this;
165+
}
166+
167+
/**
168+
* {@inheritDoc ComponentBuilder.toJSON}
169+
*/
170+
public override toJSON(): APICheckboxGroupComponent {
171+
const data = {
172+
...this.data,
173+
options: this.options.map((option) => option.toJSON()),
174+
};
175+
176+
checkboxGroupPredicate.parse(data);
177+
178+
return data as APICheckboxGroupComponent;
179+
}
180+
}

0 commit comments

Comments
 (0)