Skip to content

Commit feafaed

Browse files
authored
feat: modals (#235)
Co-authored-by: Brooke <46959407+nimajnebec@users.noreply.github.com>
1 parent 3278cf2 commit feafaed

File tree

16 files changed

+401
-70
lines changed

16 files changed

+401
-70
lines changed

.changeset/four-buses-knock.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"jellycommands": minor
3+
---
4+
5+
feat: add modal component

packages/docs/astro.config.mjs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,10 +88,23 @@ export default defineConfig({
8888
},
8989
],
9090
},
91+
{
92+
label: 'Modals',
93+
items: [
94+
{
95+
label: 'Creating Modals',
96+
link: '/components/modals',
97+
},
98+
],
99+
},
91100
{
92101
label: 'Props',
93102
link: '/components/props',
94103
},
104+
{
105+
label: 'Custom Ids',
106+
link: '/components/custom-ids',
107+
},
95108
{
96109
label: 'Deferring Interactions',
97110
link: '/components/deferring',
31.3 KB
Loading
18.1 KB
Loading

packages/docs/src/content/docs/components/buttons/index.mdx

Lines changed: 1 addition & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -99,73 +99,4 @@ Now when we click the button we see that it sends our "Hello World" response!
9999

100100
![the button works]($assets/docs/working-button.png)
101101

102-
## Custom Id
103-
104-
Each button needs to be given a "custom id" when you create it, so when you handle a button press you can use the correct handler. The simplest example, as we saw above, is just to use a static custom id.
105-
106-
```js {4}
107-
import { button } from 'jellycommands';
108-
109-
export default button({
110-
id: 'test',
111-
112-
async run({ interaction }) {},
113-
});
114-
```
115-
116-
Unlike commands, we don't need to tell Discord about the buttons before we use them. They are effectively created every time you reply to an interaction with them. This means that our custom ids can be dynamic! This can simplify some interactions since we can store some information on the id. In order to make this possible the `id` option on a button component can also be regex or a function.
117-
118-
### Regex
119-
120-
This regex will be used to see if we have found a match for the button interaction. It should always start with `^` and `$` to ensure it's matching the whole id rather than just a section of it, and not be global.
121-
122-
```js
123-
import { button } from 'jellycommands';
124-
125-
export default button({
126-
id: /^page_\d+$/,
127-
128-
async run({ interaction }) {
129-
console.log(interaction.customId);
130-
},
131-
});
132-
```
133-
134-
### Matcher Function
135-
136-
This function is passed the custom id, and then should return a boolean that indicates whether a match has been found.
137-
138-
```js
139-
import { button } from 'jellycommands';
140-
141-
export default button({
142-
id: (customId) => {
143-
return customId.startsWith('page_');
144-
},
145-
146-
async run({ interaction }) {
147-
console.log(interaction.customId);
148-
},
149-
});
150-
```
151-
152-
### Deferring
153-
154-
By default Discord requires you to respond to a button within 3 seconds, otherwise it marks the interaction as failed. Often it'll take longer than 3 seconds to respond, so you need to "defer" your reply. If you defer your command you need to use `followUp` instead of reply:
155-
156-
```js {6,10} ins="followUp" del="reply"
157-
import { button } from 'jellycommands';
158-
159-
export default button({
160-
id: 'test',
161-
162-
defer: true,
163-
164-
async run({ interaction }) {
165-
await interaction.reply('Hello World');
166-
await interaction.followUp('Hello World');
167-
},
168-
});
169-
```
170-
171-
[Read more on deferring](/components/deferring).
102+
The "custom id" system is very powerful, allowing for dynamic matching to store arbitrary data. [Learn more about custom ids](/components/custom-ids).
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
---
2+
title: Custom Ids
3+
description: Learn how to leverage custom ids with modals and buttons.
4+
---
5+
6+
Buttons and modals need to be given a "custom id" when you create them, so that the correct handler is used. The simplest way to do this is just to use a static custom id.
7+
8+
```js {4}
9+
import { button } from 'jellycommands';
10+
11+
export default button({
12+
id: 'test',
13+
14+
async run({ interaction }) {},
15+
});
16+
```
17+
18+
Unlike commands, we don't need to tell Discord about buttons and modals before we use them. They are effectively created every time you reply to an interaction with them. This means that our custom ids can be dynamic! This can simplify some interactions since we can store some information on the id. In order to make this possible the `id` option on a button component can also be regex or a function.
19+
20+
### Regex
21+
22+
This regex will be used to see if we have found a match for the button interaction. It should always start with `^` and `$` to ensure it's matching the whole id rather than just a section of it, and not be global.
23+
24+
```js
25+
import { button } from 'jellycommands';
26+
27+
export default button({
28+
id: /^page_\d+$/,
29+
30+
async run({ interaction }) {
31+
console.log(interaction.customId);
32+
},
33+
});
34+
```
35+
36+
### Matcher Function
37+
38+
This function is passed the custom id, and then should return a boolean that indicates whether a match has been found.
39+
40+
```js
41+
import { modal } from 'jellycommands';
42+
43+
export default modal({
44+
id: (customId) => {
45+
return customId.startsWith('page_');
46+
},
47+
48+
async run({ interaction }) {
49+
console.log(interaction.customId);
50+
},
51+
});
52+
```
53+
54+
### Deferring
55+
56+
By default Discord requires you to respond to an interaction within 3 seconds, otherwise it marks the interaction as failed. Often it'll take longer than 3 seconds to respond, so you need to "defer" your reply. If you defer your command you need to use `followUp` instead of reply:
57+
58+
```js {6,10} ins="followUp" del="reply"
59+
import { button } from 'jellycommands';
60+
61+
export default button({
62+
id: 'test',
63+
64+
defer: true,
65+
66+
async run({ interaction }) {
67+
await interaction.reply('Hello World');
68+
await interaction.followUp('Hello World');
69+
},
70+
});
71+
```
72+
73+
[Read more on deferring](/components/deferring).
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
---
2+
title: Modals
3+
description: Learn about how to create modals with JellyCommands.
4+
---
5+
6+
import { Tabs, TabItem } from '@astrojs/starlight/components';
7+
8+
A modal component can be used to respond to modal submissions. You'll need to create and send these modals yourself, so let's get started by creating a simple slash command to do so:
9+
10+
<Tabs>
11+
<TabItem label="TypeScript">
12+
```ts {2-7,14-18,20-21,23-27,29-30}
13+
import { command } from 'jellycommands';
14+
import {
15+
TextInputBuilder,
16+
TextInputStyle,
17+
ModalBuilder,
18+
ActionRowBuilder,
19+
} from 'discord.js';
20+
21+
export default command({
22+
name: 'test-modal',
23+
description: 'Shows a modal with a text input',
24+
25+
async run({ interaction }) {
26+
// Create a text input component with the builder
27+
const nameInput = new TextInputBuilder()
28+
.setCustomId('nameInput')
29+
.setLabel('Whats your name?')
30+
.setStyle(TextInputStyle.Short);
31+
32+
// All components need to be in a "row"
33+
const row = new ActionRowBuilder<TextInputBuilder>()).addComponents(nameInput);
34+
35+
// Create the actual modal
36+
const modal = new ModalBuilder()
37+
.setCustomId('test')
38+
.setTitle('Whats Your Name?')
39+
.addComponents(row);
40+
41+
// Send the modal
42+
interaction.showModal(modal);
43+
},
44+
});
45+
```
46+
47+
</TabItem>
48+
49+
<TabItem label="JavaScript">
50+
```js {2-7,14-18,20-23,25-29,31-32}
51+
import { command } from 'jellycommands';
52+
import {
53+
TextInputBuilder,
54+
TextInputStyle,
55+
ModalBuilder,
56+
ActionRowBuilder,
57+
} from 'discord.js';
58+
59+
export default command({
60+
name: 'test-modal',
61+
description: 'Shows a modal with a text input',
62+
63+
async run({ interaction }) {
64+
// Create a text input component with the builder
65+
const nameInput = new TextInputBuilder()
66+
.setCustomId('nameInput')
67+
.setLabel('Whats your name?')
68+
.setStyle(TextInputStyle.Short);
69+
70+
// All components need to be in a "row"
71+
const row = /** @type {ActionRowBuilder<TextInputBuilder>} */ (
72+
new ActionRowBuilder()
73+
).addComponents(nameInput);
74+
75+
// Create the actual modal
76+
const modal = new ModalBuilder()
77+
.setCustomId('test')
78+
.setTitle('Whats Your Name?')
79+
.addComponents(row);
80+
81+
// Send the modal
82+
interaction.showModal(modal);
83+
},
84+
});
85+
```
86+
87+
</TabItem>
88+
</Tabs>
89+
90+
On running this command, your modal is opened! However, you'll notice that when you submit the modal it fails:
91+
92+
!["Something went wrong" message on the modal created earlier]($assets/docs/modal-failed.png)
93+
94+
This is where the JellyCommands comes in, it allows us to create a modal component that can respond to these submissions. All we need is the modal's "custom id", which you might have noticed us setting in the above example. From here we can read the value of the text input using [`interaction.fields.getTextInputValue`](https://discordjs.guide/interactions/modals.html#extracting-data-from-modal-submissions).
95+
96+
```ts
97+
import { modal } from 'jellycommands';
98+
99+
export default modal({
100+
id: 'test',
101+
102+
async run({ interaction }) {
103+
interaction.reply({
104+
content: `Hello, ${interaction.fields.getTextInputValue('nameInput')}`,
105+
});
106+
},
107+
});
108+
```
109+
110+
Now when we submit the modal we see that it sends our response!
111+
112+
![the modal works]($assets/docs/working-modal.png)
113+
114+
The "custom id" system is very powerful, allowing for dynamic matching to store arbitrary data. [Learn more about custom ids](/components/custom-ids).

packages/jellycommands/src/components/buttons/options.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { z } from 'zod';
66
export interface ButtonOptions extends BaseComponentOptions {
77
/**
88
* The customId of the button, or a regex/function to match against
9+
* @see https://jellycommands.dev/components/custom-ids
910
*/
1011
id: string | RegExp | ((id: string) => MaybePromise<boolean>);
1112

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { type ModalOptions, modalSchema } from './options';
2+
import type { JellyCommands } from '../../JellyCommands';
3+
import type { ModalSubmitInteraction } from 'discord.js';
4+
import { Component, isComponent } from '../components';
5+
import type { MaybePromise } from '../../utils/types';
6+
import { MODALS_COMPONENT_ID } from './plugin';
7+
import { parseSchema } from '../../utils/zod';
8+
9+
export type ModalCallback = (context: {
10+
client: JellyCommands;
11+
props: Props;
12+
interaction: ModalSubmitInteraction;
13+
}) => MaybePromise<any>;
14+
15+
/**
16+
* Represents a modal.
17+
* @see https://jellycommands.dev/components/modals
18+
*/
19+
export class Modal extends Component<ModalOptions> {
20+
public readonly options: ModalOptions;
21+
22+
constructor(
23+
_options: ModalOptions,
24+
public readonly run: ModalCallback,
25+
) {
26+
super(MODALS_COMPONENT_ID, 'Modal');
27+
this.options = parseSchema('modal', modalSchema, _options);
28+
}
29+
30+
static is(item: any): item is Modal {
31+
return isComponent(item) && item.id === MODALS_COMPONENT_ID;
32+
}
33+
}
34+
35+
/**
36+
* Creates a modal.
37+
* @see https://jellycommands.dev/components/modals
38+
*/
39+
export const modal = (options: ModalOptions & { run: ModalCallback }) => {
40+
const { run, ...rest } = options;
41+
return new Modal(rest, run);
42+
};
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import type { InteractionDeferReplyOptions } from 'discord.js';
2+
import type { BaseComponentOptions } from '../components';
3+
import type { MaybePromise } from '../../utils/types';
4+
import { z } from 'zod';
5+
6+
export interface ModalOptions extends BaseComponentOptions {
7+
/**
8+
* The customId of the modal, or a regex/function to match against
9+
* @see https://jellycommands.dev/components/custom-ids
10+
*/
11+
id: string | RegExp | ((id: string) => MaybePromise<boolean>);
12+
13+
/**
14+
* Should the interaction be defered?
15+
*/
16+
defer?: boolean | InteractionDeferReplyOptions;
17+
}
18+
19+
export const modalSchema = z.object({
20+
id: z.union([
21+
z.string(),
22+
z.instanceof(RegExp),
23+
// todo test this
24+
z
25+
.function()
26+
.args(z.string().optional())
27+
.returns(z.union([z.boolean(), z.promise(z.boolean())])),
28+
]),
29+
30+
defer: z
31+
.union([
32+
z.boolean().default(false),
33+
z.object({
34+
ephemeral: z.boolean().optional(),
35+
fetchReply: z.boolean().optional(),
36+
}),
37+
])
38+
.optional(),
39+
40+
disabled: z.boolean().default(false).optional(),
41+
});

0 commit comments

Comments
 (0)