refactor(menu): Made sure the menu can be used for more than group
This commit is contained in:
parent
a79898b2e9
commit
1d73ee8a78
16 changed files with 650 additions and 406 deletions
12
source/Menu/InvalidTraversalRouteError.ts
Normal file
12
source/Menu/InvalidTraversalRouteError.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import {TraversalPath} from "./MenuTraversal.types";
|
||||
|
||||
export class InvalidTraversalRouteError extends Error {
|
||||
name = "InvalidTraversalRouteError"
|
||||
|
||||
constructor(
|
||||
public readonly path: TraversalPath
|
||||
) {
|
||||
super(`Menu found invalid traversal route: ${path.join('/')}`);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,162 @@
|
|||
import {EventHandler} from "../Events/EventHandler";
|
||||
import {Container} from "../Container/Container";
|
||||
import {AnyMenuItem, MenuAction, MenuItemType, TraversalPath} from "./MenuRenderer.types";
|
||||
import {randomUUID} from "node:crypto";
|
||||
import {ActionRowBuilder, ButtonBuilder, ButtonStyle, CommandInteraction, EmbedBuilder, MessageFlags} from "discord.js";
|
||||
import {Nullable} from "../types/Nullable";
|
||||
import {IconCache} from "../Icons/IconCache";
|
||||
import {MessageActionRowComponentBuilder} from "@discordjs/builders";
|
||||
import {ComponentInteractionEvent} from "../Events/ComponentInteractionEvent";
|
||||
import {MenuTraversal} from "./MenuTraversal";
|
||||
|
||||
export class MenuRenderer {
|
||||
private readonly menuId: string;
|
||||
private eventId: Nullable<string>;
|
||||
|
||||
private exitButton: ButtonBuilder;
|
||||
private backButton: ButtonBuilder;
|
||||
|
||||
constructor(
|
||||
private readonly traversal: MenuTraversal,
|
||||
private readonly eventHandler: EventHandler|null = null,
|
||||
private readonly iconCache: Nullable<IconCache> = null
|
||||
) {
|
||||
this.eventHandler ??= Container.get<EventHandler>(EventHandler.name);
|
||||
this.iconCache ??= Container.get<IconCache>(IconCache.name);
|
||||
this.menuId = randomUUID();
|
||||
|
||||
this.exitButton = new ButtonBuilder()
|
||||
.setLabel("Exit")
|
||||
.setStyle(ButtonStyle.Danger)
|
||||
.setCustomId(this.getInteractionId("EXIT"))
|
||||
.setEmoji(this.iconCache?.get("door_open_solid_white") ?? '');
|
||||
|
||||
this.backButton = new ButtonBuilder()
|
||||
.setLabel("Back")
|
||||
.setStyle(ButtonStyle.Secondary)
|
||||
.setCustomId(this.getInteractionId("MOVE", "BACK"))
|
||||
.setEmoji(this.iconCache?.get("angle_left_solid") ?? '');
|
||||
}
|
||||
|
||||
public async display(interaction: CommandInteraction) {
|
||||
this.eventId = this.eventHandler?.addHandler<ComponentInteractionEvent>(ComponentInteractionEvent.name, this.handleUIEvents.bind(this));
|
||||
|
||||
await interaction.reply({
|
||||
content: "",
|
||||
components: this.getActionRows(),
|
||||
embeds: [this.getEmbed()],
|
||||
withResponse: true,
|
||||
flags: MessageFlags.Ephemeral
|
||||
})
|
||||
}
|
||||
|
||||
public close() {
|
||||
this.eventHandler?.removeHandler(ComponentInteractionEvent.name, this.eventId ?? '');
|
||||
}
|
||||
|
||||
private getActionRows(): ActionRowBuilder<MessageActionRowComponentBuilder>[] {
|
||||
const navigation = new ActionRowBuilder<ButtonBuilder>()
|
||||
|
||||
if (!this.traversal.isRoot) {
|
||||
navigation.addComponents(this.backButton);
|
||||
}
|
||||
|
||||
const rows = [
|
||||
this.getComponentForMenuItem(this.traversal.currentMenuItem),
|
||||
navigation
|
||||
];
|
||||
|
||||
return rows.filter(row => row.components.length > 0);
|
||||
}
|
||||
|
||||
private getComponentForMenuItem(menuItem: AnyMenuItem): ActionRowBuilder<MessageActionRowComponentBuilder> {
|
||||
if (menuItem.type === MenuItemType.Collection) {
|
||||
const navigation = new ActionRowBuilder<ButtonBuilder>();
|
||||
navigation.setComponents(
|
||||
...menuItem.children.map(item => new ButtonBuilder()
|
||||
.setLabel(item.label)
|
||||
.setStyle(ButtonStyle.Primary)
|
||||
.setCustomId(this.getInteractionId("MOVE", item.traversalKey))
|
||||
.setEmoji(this.iconCache?.get(item.type === MenuItemType.Field ? 'pen_solid' : "folder_solid") ?? '')
|
||||
)
|
||||
)
|
||||
|
||||
return navigation
|
||||
}
|
||||
|
||||
if (menuItem.type === MenuItemType.Field) {
|
||||
const action = menuItem.getActionRowBuilder({
|
||||
path: this.traversal.path
|
||||
})
|
||||
action.setCustomId(this.getInteractionId("SET", this.traversal.stringifiedPath));
|
||||
return new ActionRowBuilder<MessageActionRowComponentBuilder>().setComponents([action]);
|
||||
}
|
||||
|
||||
return new ActionRowBuilder();
|
||||
}
|
||||
|
||||
private getEmbed(): EmbedBuilder {
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(this.traversal.currentMenuItem.label)
|
||||
.setDescription(this.traversal.currentMenuItem.description ?? '')
|
||||
.setAuthor({
|
||||
name: "/ " + this.traversal.path.join(' / ')
|
||||
});
|
||||
|
||||
if (this.traversal.currentMenuItem.type === MenuItemType.Field) {
|
||||
const currentValue = this.traversal.currentMenuItem.getCurrentValue({
|
||||
path: this.traversal.path
|
||||
});
|
||||
embed.addFields({
|
||||
name: "Current Value",
|
||||
value: currentValue
|
||||
});
|
||||
}
|
||||
|
||||
return embed;
|
||||
}
|
||||
|
||||
private getInteractionId(action: MenuAction, parameter: Nullable<string> = undefined): string {
|
||||
return `${this.menuId};${action};${parameter ?? ''}`
|
||||
}
|
||||
|
||||
private handleUIEvents(ev: ComponentInteractionEvent) {
|
||||
if (!ev.interaction.customId.startsWith(this.menuId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [, action, parameter ] = ev.interaction.customId.split(';')
|
||||
const menuAction = <MenuAction>action;
|
||||
|
||||
switch (menuAction) {
|
||||
case "MOVE":
|
||||
if (parameter === 'BACK') {
|
||||
this.traversal.travelBack();
|
||||
break;
|
||||
}
|
||||
|
||||
this.traversal.travelForward(parameter);
|
||||
break;
|
||||
case "SET":
|
||||
{
|
||||
const value = ev.interaction.isAnySelectMenu() ? ev.interaction.values : [""];
|
||||
const menuItem = this.traversal.getMenuItem(parameter);
|
||||
|
||||
if (menuItem.type !== MenuItemType.Field) {
|
||||
break;
|
||||
}
|
||||
|
||||
menuItem.setValue(value, {
|
||||
path: MenuTraversal.unstringifyTraversalPath(parameter)
|
||||
})
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
ev.interaction.update({
|
||||
components: this.getActionRows(),
|
||||
embeds: [ this.getEmbed() ]
|
||||
})
|
||||
}
|
||||
}
|
||||
43
source/Menu/MenuRenderer.types.ts
Normal file
43
source/Menu/MenuRenderer.types.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import {MessageActionRowComponentBuilder} from "@discordjs/builders";
|
||||
import {TraversalKey, TraversalPath} from "./MenuTraversal.types";
|
||||
import {MenuTraversal} from "./MenuTraversal";
|
||||
import {Snowflake, TextInputBuilder} from "discord.js";
|
||||
|
||||
export enum MenuItemType {
|
||||
Collection,
|
||||
Field,
|
||||
Prompt
|
||||
}
|
||||
|
||||
export type MenuItem = {
|
||||
traversalKey: TraversalKey,
|
||||
label: string,
|
||||
description?: string
|
||||
}
|
||||
|
||||
export type CollectionMenuItem = MenuItem & {
|
||||
type: MenuItemType.Collection,
|
||||
children: AnyMenuItem[]
|
||||
}
|
||||
|
||||
export type FieldMenuItem = MenuItem & {
|
||||
type: MenuItemType.Field,
|
||||
getCurrentValue(context: FieldMenuItemContext): string,
|
||||
getActionRowBuilder(context: FieldMenuItemContext): MessageActionRowComponentBuilder,
|
||||
setValue(value: FieldMenuItemSaveValue[], context: FieldMenuItemContext): void
|
||||
}
|
||||
export type PromptMenuItem = MenuItem & {
|
||||
type: MenuItemType.Prompt,
|
||||
getCurrentValue(context: FieldMenuItemContext): string,
|
||||
getActionRowBuilder(context: FieldMenuItemContext): TextInputBuilder,
|
||||
setValue(value: string, context: FieldMenuItemContext): void
|
||||
}
|
||||
|
||||
export type AnyMenuItem = CollectionMenuItem | FieldMenuItem | PromptMenuItem;
|
||||
export type MenuAction = "MOVE"|"SET"|"EXIT";
|
||||
|
||||
export type FieldMenuItemContext = {
|
||||
path: TraversalPath,
|
||||
}
|
||||
|
||||
export type FieldMenuItemSaveValue = string | Snowflake;
|
||||
105
source/Menu/MenuTraversal.ts
Normal file
105
source/Menu/MenuTraversal.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import {AnyMenuItem, MenuItemType} from "./MenuRenderer.types";
|
||||
import {TraversalMap, StringifiedTraversalPath, TraversalPath, TraversalKey} from "./MenuTraversal.types";
|
||||
import {InvalidTraversalRouteError} from "./InvalidTraversalRouteError";
|
||||
|
||||
export class MenuTraversal {
|
||||
private readonly traversalMap: TraversalMap
|
||||
private currentPath: TraversalPath = [];
|
||||
|
||||
public get path() {
|
||||
return this.currentPath;
|
||||
}
|
||||
|
||||
public get stringifiedPath(): StringifiedTraversalPath {
|
||||
return this.stringifyTraversalPath(this.currentPath);
|
||||
}
|
||||
|
||||
public get currentMenuItem(): AnyMenuItem {
|
||||
const path = this.stringifiedPath;
|
||||
if (!this.traversalMap.has(path)) {
|
||||
throw new InvalidTraversalRouteError(this.currentPath);
|
||||
}
|
||||
|
||||
return <AnyMenuItem>this.traversalMap.get(path);
|
||||
}
|
||||
|
||||
public get isRoot(): boolean {
|
||||
return this.currentPath.length === 0;
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly menu: AnyMenuItem[],
|
||||
private readonly rootLabel: string = "",
|
||||
private readonly rootDescription: string = ''
|
||||
) {
|
||||
this.traversalMap = this.generateTraversalMap();
|
||||
|
||||
}
|
||||
|
||||
public travelForward(next: TraversalKey) {
|
||||
const nextPath = [
|
||||
...this.currentPath,
|
||||
next
|
||||
];
|
||||
|
||||
if (!this.traversalMap.has(this.stringifyTraversalPath(nextPath))) {
|
||||
throw new InvalidTraversalRouteError(nextPath);
|
||||
}
|
||||
|
||||
this.currentPath = nextPath;
|
||||
}
|
||||
|
||||
public travelBack() {
|
||||
this.currentPath.pop();
|
||||
}
|
||||
|
||||
public getMenuItem(path: StringifiedTraversalPath): AnyMenuItem
|
||||
{
|
||||
if (!this.traversalMap.has(path)) {
|
||||
throw new InvalidTraversalRouteError([path]);
|
||||
}
|
||||
|
||||
return <AnyMenuItem>this.traversalMap.get(path);
|
||||
}
|
||||
|
||||
public static unstringifyTraversalPath(path: StringifiedTraversalPath): TraversalPath {
|
||||
return path.split('/');
|
||||
}
|
||||
|
||||
private generateTraversalMap(): TraversalMap {
|
||||
const map = new Map<StringifiedTraversalPath, AnyMenuItem>();
|
||||
|
||||
map.set('', {
|
||||
traversalKey: '',
|
||||
label: this.rootLabel,
|
||||
description: this.rootDescription,
|
||||
type: MenuItemType.Collection,
|
||||
children: this.menu
|
||||
});
|
||||
|
||||
const that = this;
|
||||
|
||||
function traversePath(path: TraversalPath, menuItem: AnyMenuItem) {
|
||||
path = [...path, menuItem.traversalKey];
|
||||
map.set(that.stringifyTraversalPath(path), menuItem);
|
||||
|
||||
if (menuItem.type !== MenuItemType.Collection) {
|
||||
return;
|
||||
}
|
||||
|
||||
menuItem.children.forEach((nextMenuItem) => {
|
||||
traversePath(path, nextMenuItem);
|
||||
})
|
||||
}
|
||||
|
||||
this.menu.forEach((menuItem) => {
|
||||
traversePath([], menuItem);
|
||||
})
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
private stringifyTraversalPath(path: TraversalPath): StringifiedTraversalPath {
|
||||
return path.join('/');
|
||||
}
|
||||
}
|
||||
7
source/Menu/MenuTraversal.types.ts
Normal file
7
source/Menu/MenuTraversal.types.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import {AnyMenuItem} from "./MenuRenderer.types";
|
||||
|
||||
export type TraversalKey = string;
|
||||
export type TraversalPath = TraversalKey[];
|
||||
export type StringifiedTraversalPath = string;
|
||||
|
||||
export type TraversalMap = Map<StringifiedTraversalPath, AnyMenuItem>;
|
||||
31
source/Menu/Modals/Modal.ts
Normal file
31
source/Menu/Modals/Modal.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import {EventHandler} from "../../Events/EventHandler";
|
||||
import {randomUUID} from "node:crypto";
|
||||
import {ModalBuilder, ModalSubmitInteraction} from "discord.js";
|
||||
import {ModalInteractionEvent} from "../../Events/ModalInteractionEvent";
|
||||
|
||||
export abstract class Modal {
|
||||
private readonly modalId: string;
|
||||
protected constructor(
|
||||
private readonly eventHandler: EventHandler
|
||||
) {
|
||||
this.modalId = randomUUID();
|
||||
}
|
||||
|
||||
protected getBuilder(): ModalBuilder {
|
||||
return new ModalBuilder()
|
||||
.setCustomId(this.modalId);
|
||||
}
|
||||
|
||||
protected awaitResponse(): Promise<ModalSubmitInteraction>
|
||||
{
|
||||
return new Promise<ModalSubmitInteraction>((resolve) => {
|
||||
this.eventHandler.addHandler<ModalInteractionEvent>(ModalInteractionEvent.name, (ev) => {
|
||||
if (this.modalId !== ev.interaction.customId) {
|
||||
return;
|
||||
}
|
||||
resolve(ev.interaction);
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
17
source/Menu/Modals/Prompt.ts
Normal file
17
source/Menu/Modals/Prompt.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import {Modal} from "./Modal";
|
||||
import {Interaction, MessageComponentInteraction, TextInputBuilder} from "discord.js";
|
||||
|
||||
export class Prompt extends Modal {
|
||||
public async requestValue(
|
||||
label: string,
|
||||
field: TextInputBuilder,
|
||||
interaction: MessageComponentInteraction
|
||||
): Promise<string> {
|
||||
const modal = this.getBuilder()
|
||||
.setTitle(label);
|
||||
|
||||
await interaction.showModal(modal);
|
||||
const responseInteraction = await this.awaitResponse();
|
||||
responseInteraction.fields.getTextInputValue()
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue