implement select

This commit is contained in:
MapleLeaf
2021-12-27 00:48:30 -06:00
parent 4f978c101a
commit ceb8e78028
14 changed files with 553 additions and 31 deletions

View File

@@ -0,0 +1,7 @@
/**
* for narrowing instance types with array.filter
*/
export const isInstanceOf =
<T>(Constructor: new (...args: any[]) => T) =>
(value: unknown): value is T =>
value instanceof Constructor

View File

@@ -18,7 +18,7 @@ export class DiscordJsAdapter implements Adapter<Discord.CommandInteraction> {
listener: (interaction: ComponentInteraction) => void, listener: (interaction: ComponentInteraction) => void,
) { ) {
this.client.on("interactionCreate", (interaction) => { this.client.on("interactionCreate", (interaction) => {
if (interaction.isButton()) { if (interaction.isMessageComponent()) {
listener(createReacordComponentInteraction(interaction)) listener(createReacordComponentInteraction(interaction))
} }
}) })
@@ -56,6 +56,7 @@ export class DiscordJsAdapter implements Adapter<Discord.CommandInteraction> {
function createReacordComponentInteraction( function createReacordComponentInteraction(
interaction: Discord.MessageComponentInteraction, interaction: Discord.MessageComponentInteraction,
): ComponentInteraction { ): ComponentInteraction {
if (interaction.isButton()) {
return { return {
type: "button", type: "button",
id: interaction.id, id: interaction.id,
@@ -65,6 +66,22 @@ function createReacordComponentInteraction(
await interaction.update(getDiscordMessageOptions(options)) await interaction.update(getDiscordMessageOptions(options))
}, },
} }
}
if (interaction.isSelectMenu()) {
return {
type: "select",
id: interaction.id,
channelId: interaction.channelId,
customId: interaction.customId,
values: interaction.values,
update: async (options) => {
await interaction.update(getDiscordMessageOptions(options))
},
}
}
raise(`Unsupported component interaction type: ${interaction.type}`)
} }
function createReacordMessage(message: Discord.Message): Message { function createReacordMessage(message: Discord.Message): Message {
@@ -94,7 +111,8 @@ function getDiscordMessageOptions(
embeds: options.embeds, embeds: options.embeds,
components: options.actionRows.map((row) => ({ components: options.actionRows.map((row) => ({
type: "ACTION_ROW", type: "ACTION_ROW",
components: row.map((component) => { components: row.map(
(component): Discord.MessageActionRowComponentOptions => {
if (component.type === "button") { if (component.type === "button") {
return { return {
type: "BUTTON", type: "BUTTON",
@@ -105,8 +123,21 @@ function getDiscordMessageOptions(
emoji: component.emoji, emoji: component.emoji,
} }
} }
if (component.type === "select") {
return {
...component,
type: "SELECT_MENU",
options: component.options.map((option) => ({
...option,
default: component.values?.includes(option.value),
})),
}
}
raise(`Unsupported component type: ${component.type}`) raise(`Unsupported component type: ${component.type}`)
}), },
),
})), })),
} }
} }

View File

@@ -0,0 +1,14 @@
import type { MessageSelectOptionOptions } from "../../internal/message"
import { Node } from "../../internal/node"
import type { OptionProps } from "./option"
export class OptionNode extends Node<OptionProps> {
get options(): MessageSelectOptionOptions {
return {
label: this.props.children || this.props.label || this.props.value || "",
value: this.props.value,
description: this.props.description,
emoji: this.props.emoji,
}
}
}

View File

@@ -0,0 +1,17 @@
import React from "react"
import { ReacordElement } from "../../internal/element"
import { OptionNode } from "./option-node"
export type OptionProps = {
label?: string
children?: string
value: string
description?: string
emoji?: string
}
export function Option(props: OptionProps) {
return (
<ReacordElement props={props} createNode={() => new OptionNode(props)} />
)
}

View File

@@ -0,0 +1,89 @@
import { nanoid } from "nanoid"
import type { ReactNode } from "react"
import React from "react"
import { isInstanceOf } from "../../../helpers/is-instance-of"
import { ReacordElement } from "../../internal/element.js"
import type { ComponentInteraction } from "../../internal/interaction"
import type { MessageOptions } from "../../internal/message"
import { getNextActionRow } from "../../internal/message"
import { Node } from "../../internal/node.js"
import { OptionNode } from "./option-node"
export type SelectProps = {
children?: ReactNode
value?: string
values?: string[]
placeholder?: string
multiple?: boolean
minValues?: number
maxValues?: number
disabled?: boolean
onSelect?: (event: SelectEvent) => void
onSelectValue?: (value: string) => void
onSelectMultiple?: (values: string[]) => void
}
export type SelectEvent = {
values: string[]
}
export function Select(props: SelectProps) {
return (
<ReacordElement props={props} createNode={() => new SelectNode(props)}>
{props.children}
</ReacordElement>
)
}
class SelectNode extends Node<SelectProps> {
readonly customId = nanoid()
override modifyMessageOptions(message: MessageOptions): void {
const actionRow = getNextActionRow(message)
const options = [...this.children]
.filter(isInstanceOf(OptionNode))
.map((node) => node.options)
const {
multiple,
value,
values,
minValues = 0,
maxValues = 25,
children,
onSelect,
onSelectValue,
onSelectMultiple,
...props
} = this.props
actionRow.push({
...props,
type: "select",
customId: this.customId,
options,
values: [...(values || []), ...(value ? [value] : [])],
minValues: multiple ? minValues : undefined,
maxValues: multiple ? Math.max(minValues, maxValues) : undefined,
})
}
override handleComponentInteraction(
interaction: ComponentInteraction,
): boolean {
if (
interaction.type === "select" &&
interaction.customId === this.customId &&
!this.props.disabled
) {
this.props.onSelect?.({ values: interaction.values })
this.props.onSelectMultiple?.(interaction.values)
if (interaction.values[0]) {
this.props.onSelectValue?.(interaction.values[0])
}
return true
}
return false
}
}

View File

@@ -25,5 +25,6 @@ export type SelectInteraction = {
id: string id: string
channelId: string channelId: string
customId: string customId: string
values: string[]
update(options: MessageOptions): Promise<void> update(options: MessageOptions): Promise<void>
} }

View File

@@ -1,13 +1,18 @@
import type { Except } from "type-fest"
import { last } from "../../helpers/last"
import type { EmbedOptions } from "../core/components/embed-options" import type { EmbedOptions } from "../core/components/embed-options"
import type { SelectProps } from "../core/components/select"
export type MessageOptions = { export type MessageOptions = {
content: string content: string
embeds: EmbedOptions[] embeds: EmbedOptions[]
actionRows: Array< actionRows: ActionRow[]
Array<MessageButtonOptions | MessageLinkOptions | MessageSelectOptions>
>
} }
type ActionRow = Array<
MessageButtonOptions | MessageLinkOptions | MessageSelectOptions
>
export type MessageButtonOptions = { export type MessageButtonOptions = {
type: "button" type: "button"
customId: string customId: string
@@ -25,12 +30,33 @@ export type MessageLinkOptions = {
disabled?: boolean disabled?: boolean
} }
export type MessageSelectOptions = { export type MessageSelectOptions = Except<SelectProps, "children" | "value"> & {
type: "select" type: "select"
customId: string customId: string
options: MessageSelectOptionOptions[]
}
export type MessageSelectOptionOptions = {
label: string
value: string
description?: string
emoji?: string
} }
export type Message = { export type Message = {
edit(options: MessageOptions): Promise<void> edit(options: MessageOptions): Promise<void>
disableComponents(): Promise<void> disableComponents(): Promise<void>
} }
export function getNextActionRow(options: MessageOptions): ActionRow {
let actionRow = last(options.actionRows)
if (
actionRow == undefined ||
actionRow.length >= 5 ||
actionRow[0]?.type === "select"
) {
actionRow = []
options.actionRows.push(actionRow)
}
return actionRow
}

View File

@@ -9,4 +9,6 @@ export * from "./core/components/embed-image"
export * from "./core/components/embed-thumbnail" export * from "./core/components/embed-thumbnail"
export * from "./core/components/embed-title" export * from "./core/components/embed-title"
export * from "./core/components/link" export * from "./core/components/link"
export * from "./core/components/option"
export * from "./core/components/select"
export * from "./core/reacord" export * from "./core/reacord"

View File

@@ -7,11 +7,13 @@ import type {
ButtonInteraction, ButtonInteraction,
CommandInteraction, CommandInteraction,
ComponentInteraction, ComponentInteraction,
SelectInteraction,
} from "./internal/interaction" } from "./internal/interaction"
import type { import type {
Message, Message,
MessageButtonOptions, MessageButtonOptions,
MessageOptions, MessageOptions,
MessageSelectOptions,
} from "./internal/message" } from "./internal/message"
export class TestAdapter implements Adapter<TestCommandInteraction> { export class TestAdapter implements Adapter<TestCommandInteraction> {
@@ -44,6 +46,20 @@ export class TestAdapter implements Adapter<TestCommandInteraction> {
raise(`Couldn't find button with label "${label}"`) raise(`Couldn't find button with label "${label}"`)
} }
findSelectByPlaceholder(placeholder: string) {
for (const message of this.messages) {
for (const component of message.options.actionRows.flat()) {
if (
component.type === "select" &&
component.placeholder === placeholder
) {
return this.createSelectActions(component, message)
}
}
}
raise(`Couldn't find select with placeholder "${placeholder}"`)
}
private createButtonActions( private createButtonActions(
button: MessageButtonOptions, button: MessageButtonOptions,
message: TestMessage, message: TestMessage,
@@ -56,6 +72,19 @@ export class TestAdapter implements Adapter<TestCommandInteraction> {
}, },
} }
} }
private createSelectActions(
component: MessageSelectOptions,
message: TestMessage,
) {
return {
select: (...values: string[]) => {
this.componentInteractionListener(
new TestSelectInteraction(component.customId, message, values),
)
},
}
}
} }
export class TestMessage implements Message { export class TestMessage implements Message {
@@ -109,3 +138,19 @@ export class TestButtonInteraction implements ButtonInteraction {
this.message.options = options this.message.options = options
} }
} }
export class TestSelectInteraction implements SelectInteraction {
readonly type = "select"
readonly id = nanoid()
readonly channelId = "test-channel-id"
constructor(
readonly customId: string,
readonly message: TestMessage,
readonly values: string[],
) {}
async update(options: MessageOptions): Promise<void> {
this.message.options = options
}
}

View File

@@ -17,10 +17,10 @@
- message components - message components
- [x] buttons - [x] buttons
- [x] links - [x] links
- [ ] select - [x] select
- [x] select onChange
- [ ] action row - [ ] action row
- [x] button onClick - [x] button onClick
- [ ] select onChange
- [x] deactivate - [x] deactivate
- [ ] destroy - [ ] destroy
- [ ] docs - [ ] docs
@@ -37,3 +37,5 @@
- [ ] `useReactions` - [ ] `useReactions`
- [ ] max instance count per guild - [ ] max instance count per guild
- [ ] max instance count per channel - [ ] max instance count per channel
- [ ] uncontrolled select
- [ ] single class/helper function for testing `ReacordTester`

View File

@@ -0,0 +1,31 @@
import React, { useState } from "react"
import { Button, Option, Select } from "../library/main"
export function FruitSelect() {
const [value, setValue] = useState<string>()
const [finalChoice, setFinalChoice] = useState<string>()
if (finalChoice) {
return <>you chose {finalChoice}</>
}
return (
<>
{"_ _"}
<Select
placeholder="choose a fruit"
value={value}
onSelectValue={setValue}
>
<Option value="🍎" />
<Option value="🍌" />
<Option value="🍒" />
</Select>
<Button
label="confirm"
disabled={value == undefined}
onClick={() => setFinalChoice(value)}
/>
</>
)
}

View File

@@ -1,9 +1,10 @@
import { Client } from "discord.js" import { Client } from "discord.js"
import "dotenv/config" import "dotenv/config"
import React from "react" import React from "react"
import { DiscordJsAdapter, Reacord } from "../library/main.js" import { DiscordJsAdapter, Reacord } from "../library/main"
import { createCommandHandler } from "./command-handler.js" import { createCommandHandler } from "./command-handler"
import { Counter } from "./counter.js" import { Counter } from "./counter"
import { FruitSelect } from "./fruit-select"
const client = new Client({ const client = new Client({
intents: ["GUILDS"], intents: ["GUILDS"],
@@ -23,6 +24,13 @@ createCommandHandler(client, [
reply.render(<Counter onDeactivate={() => reply.deactivate()} />) reply.render(<Counter onDeactivate={() => reply.deactivate()} />)
}, },
}, },
{
name: "select",
description: "shows a select",
run: (interaction) => {
reacord.createCommandReply(interaction).render(<FruitSelect />)
},
},
]) ])
await client.login(process.env.TEST_BOT_TOKEN) await client.login(process.env.TEST_BOT_TOKEN)

247
test/select.test.tsx Normal file
View File

@@ -0,0 +1,247 @@
import React, { useState } from "react"
import { Button, Option, Select } from "../library/main"
import { setupReacordTesting } from "./setup-testing"
const { adapter, reply, assertMessages } = setupReacordTesting()
test("single select", async () => {
function TestSelect() {
const [value, setValue] = useState<string>()
const [disabled, setDisabled] = useState(false)
return (
<>
<Select
placeholder="choose one"
value={value}
onSelectValue={setValue}
disabled={disabled}
>
<Option value="1">one</Option>
<Option value="2">two</Option>
<Option value="3">three</Option>
</Select>
<Button label="disable" onClick={() => setDisabled(true)} />
</>
)
}
reply.render(<TestSelect />)
await assertMessages([
{
content: "",
embeds: [],
actionRows: [
[
{
type: "select",
placeholder: "choose one",
values: [],
disabled: false,
options: [
{ label: "one", value: "1" },
{ label: "two", value: "2" },
{ label: "three", value: "3" },
],
},
],
[{ type: "button", style: "secondary", label: "disable" }],
],
},
])
adapter.findSelectByPlaceholder("choose one").select("2")
await assertMessages([
{
content: "",
embeds: [],
actionRows: [
[
{
type: "select",
placeholder: "choose one",
values: ["2"],
disabled: false,
options: [
{ label: "one", value: "1" },
{ label: "two", value: "2" },
{ label: "three", value: "3" },
],
},
],
[{ type: "button", style: "secondary", label: "disable" }],
],
},
])
adapter.findButtonByLabel("disable").click()
await assertMessages([
{
content: "",
embeds: [],
actionRows: [
[
{
type: "select",
placeholder: "choose one",
values: ["2"],
disabled: true,
options: [
{ label: "one", value: "1" },
{ label: "two", value: "2" },
{ label: "three", value: "3" },
],
},
],
[{ type: "button", style: "secondary", label: "disable" }],
],
},
])
adapter.findSelectByPlaceholder("choose one").select("1")
await assertMessages([
{
content: "",
embeds: [],
actionRows: [
[
{
type: "select",
placeholder: "choose one",
values: ["2"],
disabled: true,
options: [
{ label: "one", value: "1" },
{ label: "two", value: "2" },
{ label: "three", value: "3" },
],
},
],
[{ type: "button", style: "secondary", label: "disable" }],
],
},
])
})
test("multiple select", async () => {
function TestSelect() {
const [values, setValues] = useState<string[]>([])
return (
<Select
placeholder="select"
multiple
values={values}
onSelectMultiple={setValues}
>
<Option value="1">one</Option>
<Option value="2">two</Option>
<Option value="3">three</Option>
</Select>
)
}
reply.render(<TestSelect />)
await assertMessages([
{
content: "",
embeds: [],
actionRows: [
[
{
type: "select",
placeholder: "select",
values: [],
minValues: 0,
maxValues: 25,
options: [
{ label: "one", value: "1" },
{ label: "two", value: "2" },
{ label: "three", value: "3" },
],
},
],
],
},
])
adapter.findSelectByPlaceholder("select").select("1", "3")
await assertMessages([
{
content: "",
embeds: [],
actionRows: [
[
{
type: "select",
placeholder: "select",
values: expect.arrayContaining(["1", "3"]),
minValues: 0,
maxValues: 25,
options: [
{ label: "one", value: "1" },
{ label: "two", value: "2" },
{ label: "three", value: "3" },
],
},
],
],
},
])
adapter.findSelectByPlaceholder("select").select("2")
await assertMessages([
{
content: "",
embeds: [],
actionRows: [
[
{
type: "select",
placeholder: "select",
values: ["2"],
minValues: 0,
maxValues: 25,
options: [
{ label: "one", value: "1" },
{ label: "two", value: "2" },
{ label: "three", value: "3" },
],
},
],
],
},
])
adapter.findSelectByPlaceholder("select").select()
await assertMessages([
{
content: "",
embeds: [],
actionRows: [
[
{
type: "select",
placeholder: "select",
values: [],
minValues: 0,
maxValues: 25,
options: [
{ label: "one", value: "1" },
{ label: "two", value: "2" },
{ label: "three", value: "3" },
],
},
],
],
},
])
})
test.todo("select minValues and maxValues")

View File

@@ -38,7 +38,9 @@ function sampleMessages(adapter: TestAdapter) {
return adapter.messages.map((message) => ({ return adapter.messages.map((message) => ({
...message.options, ...message.options,
actionRows: message.options.actionRows.map((row) => actionRows: message.options.actionRows.map((row) =>
row.map((component) => omit(component, ["customId"])), row.map((component) =>
omit(component, ["customId", "onClick", "onSelect", "onSelectValue"]),
),
), ),
})) }))
} }