feat(string-prompt): Adds string prompt to menu item

This commit is contained in:
Michel Fedde 2025-06-22 14:41:18 +02:00
parent 1d73ee8a78
commit 5c7c9c9f87
6 changed files with 164 additions and 26 deletions

View file

@ -13,7 +13,8 @@ import {
inlineCode, inlineCode,
italic, italic,
Snowflake, Snowflake,
StringSelectMenuBuilder, StringSelectMenuOptionBuilder StringSelectMenuBuilder, StringSelectMenuOptionBuilder, TextInputBuilder,
TextInputStyle
} from "discord.js"; } from "discord.js";
import {ChannelId} from "../types/DiscordTypes"; import {ChannelId} from "../types/DiscordTypes";
import {MessageActionRowComponentBuilder} from "@discordjs/builders"; import {MessageActionRowComponentBuilder} from "@discordjs/builders";
@ -80,8 +81,25 @@ export class ConfigurationMenuHandler {
setValue: this.setValue.bind(this) setValue: this.setValue.bind(this)
} }
] ]
},
{
traversalKey: "calendar",
label: "Calendar",
description: "Provides settings for the metadata contained in the playdate exports.",
type: MenuItemType.Collection,
children: [
{
traversalKey: "title",
label: "Title",
description: "Defines how the calendar entry should be called.",
type: MenuItemType.Prompt,
getCurrentValue: this.getStringValue.bind(this),
getActionRowBuilder: this.getStringBuilder.bind(this),
setValue: this.setValue.bind(this)
} }
] ]
},
]
} }
private getChannelValue(context: FieldMenuItemContext): string { private getChannelValue(context: FieldMenuItemContext): string {
@ -158,7 +176,29 @@ export class ConfigurationMenuHandler {
) )
} }
private setValue(value: FieldMenuItemSaveValue[], context: FieldMenuItemContext): void { private getStringValue(context: FieldMenuItemContext): string {
this.configuration.saveConfiguration(context.path.join('.'), value.join('; ')); const value = this.configuration.getConfigurationByPath(context.path.join('.'));
if (!value) {
return "";
}
if (typeof value !== 'string') {
throw new TypeError(`Value of type ${typeof value} can't be used for a string value!`)
}
return value;
}
private getStringBuilder(context: FieldMenuItemContext): TextInputBuilder {
return new TextInputBuilder()
.setStyle(TextInputStyle.Short)
}
private setValue(value: FieldMenuItemSaveValue[]|string, context: FieldMenuItemContext): void {
const savedValue = typeof value !== 'string' ?
value.join('; ') :
value;
this.configuration.saveConfiguration(context.path.join('.'), savedValue);
} }
} }

View file

@ -15,6 +15,9 @@ export class GroupConfigurationHandler {
locale: new Intl.Locale('en-GB'), locale: new Intl.Locale('en-GB'),
permissions: { permissions: {
allowMemberManagingPlaydates: false allowMemberManagingPlaydates: false
},
calendar: {
title: null
} }
} }

View file

@ -7,6 +7,7 @@ export enum TransformerType {
Locale, Locale,
Channel, Channel,
PermissionBoolean, PermissionBoolean,
String
} }
type GroupConfigurationTransformer = { type GroupConfigurationTransformer = {
@ -15,7 +16,7 @@ type GroupConfigurationTransformer = {
} }
export type GroupConfigurationResult = export type GroupConfigurationResult =
ChannelId | Intl.Locale | boolean ChannelId | Intl.Locale | boolean | string
export class GroupConfigurationTransformers { export class GroupConfigurationTransformers {
static TRANSFORMERS: GroupConfigurationTransformer[] = [ static TRANSFORMERS: GroupConfigurationTransformer[] = [
@ -34,6 +35,10 @@ export class GroupConfigurationTransformers {
{ {
path: ['permissions', 'allowMemberManagingPlaydates'], path: ['permissions', 'allowMemberManagingPlaydates'],
type: TransformerType.PermissionBoolean type: TransformerType.PermissionBoolean
},
{
path: ['calendar', 'title'],
type: TransformerType.String
} }
]; ];
@ -50,6 +55,8 @@ export class GroupConfigurationTransformers {
return <ChannelId>configValue.value; return <ChannelId>configValue.value;
case TransformerType.PermissionBoolean: case TransformerType.PermissionBoolean:
return configValue.value === '1'; return configValue.value === '1';
case TransformerType.String:
return configValue.value;
} }
} }

View file

@ -4,7 +4,8 @@ import {Nullable} from "../types/Nullable";
export type RuntimeGroupConfiguration = { export type RuntimeGroupConfiguration = {
channels: Nullable<ChannelRuntimeGroupConfiguration>, channels: Nullable<ChannelRuntimeGroupConfiguration>,
locale: Intl.Locale, locale: Intl.Locale,
permissions: PermissionRuntimeGroupConfiguration permissions: PermissionRuntimeGroupConfiguration,
calendar: CalendarRuntimeGroupConfiguration
}; };
export type ChannelRuntimeGroupConfiguration = { export type ChannelRuntimeGroupConfiguration = {
@ -15,3 +16,7 @@ export type ChannelRuntimeGroupConfiguration = {
export type PermissionRuntimeGroupConfiguration = { export type PermissionRuntimeGroupConfiguration = {
allowMemberManagingPlaydates: boolean allowMemberManagingPlaydates: boolean
} }
export type CalendarRuntimeGroupConfiguration = {
title: null|string
}

View file

@ -2,12 +2,21 @@ import {EventHandler} from "../Events/EventHandler";
import {Container} from "../Container/Container"; import {Container} from "../Container/Container";
import {AnyMenuItem, MenuAction, MenuItemType, TraversalPath} from "./MenuRenderer.types"; import {AnyMenuItem, MenuAction, MenuItemType, TraversalPath} from "./MenuRenderer.types";
import {randomUUID} from "node:crypto"; import {randomUUID} from "node:crypto";
import {ActionRowBuilder, ButtonBuilder, ButtonStyle, CommandInteraction, EmbedBuilder, MessageFlags} from "discord.js"; import {
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
CommandInteraction,
EmbedBuilder,
MessageComponentInteraction,
MessageFlags
} from "discord.js";
import {Nullable} from "../types/Nullable"; import {Nullable} from "../types/Nullable";
import {IconCache} from "../Icons/IconCache"; import {IconCache} from "../Icons/IconCache";
import {MessageActionRowComponentBuilder} from "@discordjs/builders"; import {MessageActionRowComponentBuilder} from "@discordjs/builders";
import {ComponentInteractionEvent} from "../Events/ComponentInteractionEvent"; import {ComponentInteractionEvent} from "../Events/ComponentInteractionEvent";
import {MenuTraversal} from "./MenuTraversal"; import {MenuTraversal} from "./MenuTraversal";
import {Prompt} from "./Modals/Prompt";
export class MenuRenderer { export class MenuRenderer {
private readonly menuId: string; private readonly menuId: string;
@ -15,6 +24,10 @@ export class MenuRenderer {
private exitButton: ButtonBuilder; private exitButton: ButtonBuilder;
private backButton: ButtonBuilder; private backButton: ButtonBuilder;
private static MAX_BUTTON_PER_ROW = 5;
private static MAX_ROWS = 5;
private static SYSTEM_ROW_COUNT = 1;
private static MAX_USER_ROW_COUNT = MenuRenderer.MAX_ROWS - MenuRenderer.SYSTEM_ROW_COUNT;
constructor( constructor(
private readonly traversal: MenuTraversal, private readonly traversal: MenuTraversal,
@ -62,26 +75,38 @@ export class MenuRenderer {
} }
const rows = [ const rows = [
this.getComponentForMenuItem(this.traversal.currentMenuItem), ...this.getComponentForMenuItem(this.traversal.currentMenuItem),
navigation navigation
]; ];
return rows.filter(row => row.components.length > 0); return rows.filter(row => row.components.length > 0);
} }
private getComponentForMenuItem(menuItem: AnyMenuItem): ActionRowBuilder<MessageActionRowComponentBuilder> { private getComponentForMenuItem(menuItem: AnyMenuItem): ActionRowBuilder<MessageActionRowComponentBuilder>[] {
if (menuItem.type === MenuItemType.Collection) { if (menuItem.type === MenuItemType.Collection) {
const navigation = new ActionRowBuilder<ButtonBuilder>(); const rowCount = Math.ceil(menuItem.children.length / MenuRenderer.MAX_BUTTON_PER_ROW);
navigation.setComponents( if (rowCount > MenuRenderer.MAX_USER_ROW_COUNT) {
...menuItem.children.map(item => new ButtonBuilder() throw new TypeError(
`A collection can only have a max of ${MenuRenderer.MAX_USER_ROW_COUNT * MenuRenderer.MAX_BUTTON_PER_ROW} entries!`
);
}
return Array.from(Array(rowCount).keys())
.map((index) => {
const childStart = index * MenuRenderer.MAX_BUTTON_PER_ROW;
return menuItem.children.toSpliced(childStart, MenuRenderer.MAX_BUTTON_PER_ROW);
})
.reverse()
.map((items) => new ActionRowBuilder<ButtonBuilder>()
.setComponents(
...items.map(item => new ButtonBuilder()
.setLabel(item.label) .setLabel(item.label)
.setStyle(ButtonStyle.Primary) .setStyle(ButtonStyle.Primary)
.setCustomId(this.getInteractionId("MOVE", item.traversalKey)) .setCustomId(this.getInteractionId("MOVE", item.traversalKey))
.setEmoji(this.iconCache?.get(item.type === MenuItemType.Field ? 'pen_solid' : "folder_solid") ?? '') .setEmoji(this.iconCache?.get(item.type === MenuItemType.Field ? 'pen_solid' : "folder_solid") ?? '')
) )
) )
)
return navigation
} }
if (menuItem.type === MenuItemType.Field) { if (menuItem.type === MenuItemType.Field) {
@ -89,10 +114,10 @@ export class MenuRenderer {
path: this.traversal.path path: this.traversal.path
}) })
action.setCustomId(this.getInteractionId("SET", this.traversal.stringifiedPath)); action.setCustomId(this.getInteractionId("SET", this.traversal.stringifiedPath));
return new ActionRowBuilder<MessageActionRowComponentBuilder>().setComponents([action]); return [new ActionRowBuilder<MessageActionRowComponentBuilder>().setComponents([action])];
} }
return new ActionRowBuilder(); return [new ActionRowBuilder()];
} }
private getEmbed(): EmbedBuilder { private getEmbed(): EmbedBuilder {
@ -120,7 +145,7 @@ export class MenuRenderer {
return `${this.menuId};${action};${parameter ?? ''}` return `${this.menuId};${action};${parameter ?? ''}`
} }
private handleUIEvents(ev: ComponentInteractionEvent) { private async handleUIEvents(ev: ComponentInteractionEvent) {
if (!ev.interaction.customId.startsWith(this.menuId)) { if (!ev.interaction.customId.startsWith(this.menuId)) {
return; return;
} }
@ -154,7 +179,38 @@ export class MenuRenderer {
} }
} }
ev.interaction.update({ let updateInteraction: unknown = ev.interaction;
if (this.traversal.currentMenuItem.type === MenuItemType.Prompt) {
const prompt = new Prompt(<EventHandler>this.eventHandler);
const currentValue = this.traversal.currentMenuItem;
const currentValuePath = [...this.traversal.path];
const row = currentValue.getActionRowBuilder({
path: currentValuePath
});
row.setLabel(currentValue.label);
row.setValue(currentValue.getCurrentValue({path: currentValuePath}));
row.setPlaceholder(currentValue.description ?? '');
this.traversal.travelBack();
const value = await prompt.requestValue(
this.traversal.currentMenuItem.label,
row,
ev.interaction
);
currentValue.setValue(
value.value,
{
path: currentValuePath
}
)
updateInteraction = value.interaction;
}
updateInteraction.update({
components: this.getActionRows(), components: this.getActionRows(),
embeds: [ this.getEmbed() ] embeds: [ this.getEmbed() ]
}) })

View file

@ -1,17 +1,44 @@
import {Modal} from "./Modal"; import {Modal} from "./Modal";
import {Interaction, MessageComponentInteraction, TextInputBuilder} from "discord.js"; import {
ActionRowBuilder,
Interaction,
MessageComponentInteraction,
ModalSubmitInteraction,
TextInputBuilder
} from "discord.js";
import {EventHandler} from "../../Events/EventHandler";
export type RequestResponse = {
interaction: ModalSubmitInteraction,
value: string
}
export class Prompt extends Modal { export class Prompt extends Modal {
constructor(eventHandler: EventHandler) {
super(eventHandler);
}
public async requestValue( public async requestValue(
label: string, label: string,
field: TextInputBuilder, field: TextInputBuilder,
interaction: MessageComponentInteraction interaction: MessageComponentInteraction
): Promise<string> { ): Promise<RequestResponse> {
const modal = this.getBuilder() const modal = this.getBuilder()
.setTitle(label); .setTitle(label);
field.setCustomId("value");
const actionRow = new ActionRowBuilder<TextInputBuilder>();
actionRow.setComponents([field])
modal.setComponents(actionRow);
await interaction.showModal(modal); await interaction.showModal(modal);
const responseInteraction = await this.awaitResponse(); const responseInteraction = await this.awaitResponse();
responseInteraction.fields.getTextInputValue() const value = responseInteraction.fields.getTextInputValue("value");
return {
value,
interaction: responseInteraction
};
} }
} }