refactor(menu): Made sure the menu can be used for more than group

This commit is contained in:
Michel Fedde 2025-06-20 17:48:00 +02:00
parent a79898b2e9
commit 1d73ee8a78
16 changed files with 650 additions and 406 deletions

View 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('/')}`);
}
}

View file

@ -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() ]
})
}
}

View 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;

View 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('/');
}
}

View 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>;

View 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);
})
})
}
}

View 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()
}
}