Skip to content

feat: strong parameter typing #236

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 5 commits into
base: main
Choose a base branch
from
Draft
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
5 changes: 5 additions & 0 deletions .changeset/four-buses-knock.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"jellycommands": minor
---

feat: add modal component
13 changes: 13 additions & 0 deletions packages/docs/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -88,10 +88,23 @@ export default defineConfig({
},
],
},
{
label: 'Modals',
items: [
{
label: 'Creating Modals',
link: '/components/modals',
},
],
},
{
label: 'Props',
link: '/components/props',
},
{
label: 'Custom Ids',
link: '/components/custom-ids',
},
{
label: 'Deferring Interactions',
link: '/components/deferring',
Expand Down
Binary file added packages/docs/src/assets/docs/modal-failed.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added packages/docs/src/assets/docs/working-modal.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
71 changes: 1 addition & 70 deletions packages/docs/src/content/docs/components/buttons/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -99,73 +99,4 @@ Now when we click the button we see that it sends our "Hello World" response!

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

## Custom Id

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.

```js {4}
import { button } from 'jellycommands';

export default button({
id: 'test',

async run({ interaction }) {},
});
```

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.

### Regex

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.

```js
import { button } from 'jellycommands';

export default button({
id: /^page_\d+$/,

async run({ interaction }) {
console.log(interaction.customId);
},
});
```

### Matcher Function

This function is passed the custom id, and then should return a boolean that indicates whether a match has been found.

```js
import { button } from 'jellycommands';

export default button({
id: (customId) => {
return customId.startsWith('page_');
},

async run({ interaction }) {
console.log(interaction.customId);
},
});
```

### Deferring

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:

```js {6,10} ins="followUp" del="reply"
import { button } from 'jellycommands';

export default button({
id: 'test',

defer: true,

async run({ interaction }) {
await interaction.reply('Hello World');
await interaction.followUp('Hello World');
},
});
```

[Read more on deferring](/components/deferring).
The "custom id" system is very powerful, allowing for dynamic matching to store arbitrary data. [Learn more about custom ids](/components/custom-ids).
73 changes: 73 additions & 0 deletions packages/docs/src/content/docs/components/custom-ids.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
---
title: Custom Ids
description: Learn how to leverage custom ids with modals and buttons.
---

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.

```js {4}
import { button } from 'jellycommands';

export default button({
id: 'test',

async run({ interaction }) {},
});
```

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.

### Regex

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.

```js
import { button } from 'jellycommands';

export default button({
id: /^page_\d+$/,

async run({ interaction }) {
console.log(interaction.customId);
},
});
```

### Matcher Function

This function is passed the custom id, and then should return a boolean that indicates whether a match has been found.

```js
import { modal } from 'jellycommands';

export default modal({
id: (customId) => {
return customId.startsWith('page_');
},

async run({ interaction }) {
console.log(interaction.customId);
},
});
```

### Deferring

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:

```js {6,10} ins="followUp" del="reply"
import { button } from 'jellycommands';

export default button({
id: 'test',

defer: true,

async run({ interaction }) {
await interaction.reply('Hello World');
await interaction.followUp('Hello World');
},
});
```

[Read more on deferring](/components/deferring).
114 changes: 114 additions & 0 deletions packages/docs/src/content/docs/components/modals/index.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
---
title: Modals
description: Learn about how to create modals with JellyCommands.
---

import { Tabs, TabItem } from '@astrojs/starlight/components';

A modal component can be used to respond to modal submissions, from modal you create. You'll need to create and send these modal yourself, so let's get started by creating a simple slash command to do so:

<Tabs>
<TabItem label="TypeScript">
```ts {2-7,14-18,20-21,23-27,29-30}
import { command } from 'jellycommands';
import {
TextInputBuilder,
TextInputStyle,
ModalBuilder,
ActionRowBuilder,
} from 'discord.js';

export default command({
name: 'test-modal',
description: 'Shows a modal with a text input',

async run({ interaction }) {
// Create a text input component with the builder
const nameInput = new TextInputBuilder()
.setCustomId('nameInput')
.setLabel('Whats your name?')
.setStyle(TextInputStyle.Short);

// All components need to be in a "row"
const row = new ActionRowBuilder<TextInputBuilder>()).addComponents(nameInput);

// Create the actual modal
const modal = new ModalBuilder()
.setCustomId('test')
.setTitle('Whats Your Name?')
.addComponents(row);

// Send the modal
interaction.showModal(modal);
},
});
```

</TabItem>

<TabItem label="JavaScript">
```js {2-7,14-18,20-23,25-29,31-32}
import { command } from 'jellycommands';
import {
TextInputBuilder,
TextInputStyle,
ModalBuilder,
ActionRowBuilder,
} from 'discord.js';

export default command({
name: 'test-modal',
description: 'Shows a modal with a text input',

async run({ interaction }) {
// Create a text input component with the builder
const nameInput = new TextInputBuilder()
.setCustomId('nameInput')
.setLabel('Whats your name?')
.setStyle(TextInputStyle.Short);

// All components need to be in a "row"
const row = /** @type {ActionRowBuilder<TextInputBuilder>} */ (
new ActionRowBuilder()
).addComponents(nameInput);

// Create the actual modal
const modal = new ModalBuilder()
.setCustomId('test')
.setTitle('Whats Your Name?')
.addComponents(row);

// Send the modal
interaction.showModal(modal);
},
});
```

</TabItem>
</Tabs>

On running this command, your modal is opened! However, you'll notice that when you submit the modal it fails:

!["Something went wrong" message on the modal created earlier]($assets/docs/modal-failed.png)

This is where the JellyCommands comes in, it allows us to create a modal component that can respond to modal 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).

```ts
import { modal } from 'jellycommands';

export default modal({
id: 'test',

async run({ interaction }) {
interaction.reply({
content: `Hello, ${interaction.fields.getTextInputValue('nameInput')}`,
});
},
});
```

Now when we submit the modal we see that it sends our response!

![the modal works]($assets/docs/working-modal.png)

The "custom id" system is very powerful, allowing for dynamic matching to store arbitrary data. [Learn more about custom ids](/components/custom-ids).
55 changes: 55 additions & 0 deletions packages/jellycommands/src/components/modals/modals.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { type ModalOptions, modalSchema, type ModalField } from './options';
import type { JellyCommands } from '../../JellyCommands';
import type { ModalSubmitInteraction } from 'discord.js';
import { Component, isComponent } from '../components';
import type { MaybePromise } from '../../utils/types';
import { MODALS_COMPONENT_ID } from './plugin';
import { parseSchema } from '../../utils/zod';

type InputComponentMapper<T extends ModalField> = {
[K in T as K['customId']]: K['required'] extends false
? string | null
: string;
};

export type ModalCallback<T extends ModalField> = (context: {
client: JellyCommands;
props: Props;
interaction: ModalSubmitInteraction;
fields: InputComponentMapper<T>;
}) => MaybePromise<any>;

/**
* Represents a modal.
* @see https://jellycommands.dev/components/modals
*/
export class Modal<T extends ModalField> extends Component<ModalOptions<T>> {
public readonly options: ModalOptions<T>;

constructor(
_options: ModalOptions<T>,
public readonly run: ModalCallback<any>,
) {
super(MODALS_COMPONENT_ID, 'Modal');
this.options = parseSchema(
'modal',
modalSchema,
_options,
) as ModalOptions<T>;
}

static is(item: any): item is Modal<any> {
return isComponent(item) && item.id === MODALS_COMPONENT_ID;
}
}

/**
* Creates a modal.
* @see https://jellycommands.dev/components/modals
*/
export const modal = <const T extends ModalField>(
options: ModalOptions<T> & { run: ModalCallback<T> },
) => {
const { run, ...rest } = options;
return new Modal(rest, run);
};
Loading