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,164 @@
import {
AnyMenuItem, FieldMenuItem,
FieldMenuItemContext, FieldMenuItemSaveValue,
MenuItemType,
RowBuilderFieldMenuItemContext
} from "../Menu/MenuRenderer.types";
import {GroupConfigurationTransformers} from "./GroupConfigurationTransformers";
import {GroupConfigurationHandler} from "./GroupConfigurationHandler";
import {
channelMention,
ChannelSelectMenuBuilder,
ChannelType,
inlineCode,
italic,
Snowflake,
StringSelectMenuBuilder, StringSelectMenuOptionBuilder
} from "discord.js";
import {ChannelId} from "../types/DiscordTypes";
import {MessageActionRowComponentBuilder} from "@discordjs/builders";
export class ConfigurationMenuHandler {
constructor(
private readonly configuration: GroupConfigurationHandler,
private readonly transformer: GroupConfigurationTransformers
) {
}
public getMenuItems(): AnyMenuItem[] {
return [
{
traversalKey: "channels",
label: "Channels",
description: "Provides settings to define in what channels the bot sends messages, when not directly interacting with it.",
type: MenuItemType.Collection,
children: [
{
traversalKey: "newPlaydates",
label: "New Playdates",
description: "Sets the channel, where the group gets notified, when new Playdates are set.",
type: MenuItemType.Field,
getCurrentValue: this.getChannelValue.bind(this),
getActionRowBuilder: this.getChannelMenuBuilder.bind(this),
setValue: this.setValue.bind(this)
},
{
traversalKey: "playdateReminders",
label: 'Playdate Reminders',
description: "Sets the channel, where the group gets reminded of upcoming playdates.",
type: MenuItemType.Field,
getCurrentValue: this.getChannelValue.bind(this),
getActionRowBuilder: this.getChannelMenuBuilder.bind(this),
setValue: this.setValue.bind(this)
}
]
},
{
traversalKey: "locale",
label: "Locale",
description: "Provides locale to be used for this group.",
type: MenuItemType.Field,
getCurrentValue: this.getLocaleValue.bind(this),
getActionRowBuilder: this.getLocaleMenuBuilder.bind(this),
setValue: this.setValue.bind(this)
},
{
traversalKey: "permissions",
label: "Permissions",
description: "Allows customization, how the members are allowed to interact with the data stored in the group.",
type: MenuItemType.Collection,
children: [
{
traversalKey: "allowMemberManagingPlaydates",
label: "Manage Playdates",
description: "Defines if the members are allowed to manage playdates like adding or deleting them.",
type: MenuItemType.Field,
getCurrentValue: this.getPermissionBooleanValue.bind(this),
getActionRowBuilder: this.getPermissionBooleanBuilder.bind(this),
setValue: this.setValue.bind(this)
}
]
}
]
}
private getChannelValue(context: FieldMenuItemContext): string {
const value = this.configuration.getConfigurationByPath(context.path.join('.'));
if (value === undefined) {
return italic("None");
}
if (!value) {
return inlineCode("None");
}
return channelMention(<ChannelId>value);
}
private getChannelMenuBuilder(context: FieldMenuItemContext): MessageActionRowComponentBuilder {
return new ChannelSelectMenuBuilder()
.setChannelTypes(ChannelType.GuildText)
.setPlaceholder("New Value");
}
private getLocaleValue(context: FieldMenuItemContext): string {
const value = this.configuration.getConfigurationByPath(context.path.join('.'));
if (value === undefined) {
return italic("None");
}
if (!value) {
return inlineCode("Default");
}
const displaynames = new Intl.DisplayNames(["en"], {type: "language"});
return displaynames.of((<Intl.Locale>value)?.baseName) ?? "Unknown";
}
private getLocaleMenuBuilder(context: FieldMenuItemContext): MessageActionRowComponentBuilder {
const options = [
'en-US',
'fr-FR',
'it-IT',
'de-DE'
]
const displaynames = new Intl.DisplayNames(["en"], {type: "language"});
return new StringSelectMenuBuilder()
.setOptions(
options.map(intl => new StringSelectMenuOptionBuilder()
.setLabel(displaynames.of(intl) ?? '')
.setValue(intl)
)
)
}
private getPermissionBooleanValue(context: FieldMenuItemContext) {
const value = this.configuration.getConfigurationByPath(context.path.join('.'));
if (value === undefined) {
return italic("None");
}
return value ? 'Allowed' : "Disallowed";
}
private getPermissionBooleanBuilder(context: FieldMenuItemContext) {
return new StringSelectMenuBuilder()
.setOptions(
[
{
label: "Allow",
value: "1"
},
{
label: "Disallow",
value: "0"
}
]
)
}
private setValue(value: FieldMenuItemSaveValue[], context: FieldMenuItemContext): void {
this.configuration.saveConfiguration(context.path.join('.'), value.join('; '));
}
}

View file

@ -1,372 +0,0 @@
import {GroupConfigurationTransformers, TransformerType} from "./GroupConfigurationTransformers";
import {GroupConfigurationHandler} from "./GroupConfigurationHandler";
import {
ActionRowBuilder,
AnySelectMenuInteraction,
ButtonBuilder,
ButtonStyle, channelMention,
ChannelSelectMenuBuilder, ChannelType,
ChatInputCommandInteraction, EmbedBuilder, inlineCode, Interaction,
InteractionReplyOptions,
InteractionUpdateOptions, italic, MessageFlags,
StringSelectMenuBuilder,
StringSelectMenuOptionBuilder, UserSelectMenuBuilder
} from "discord.js";
import {Logger} from "log4js";
import {Container} from "../Container/Container";
import {Nullable} from "../types/Nullable";
import {
MentionableSelectMenuBuilder,
MessageActionRowComponentBuilder,
RoleSelectMenuBuilder
} from "@discordjs/builders";
import {ChannelId} from "../types/DiscordTypes";
import {IconCache} from "../Icons/IconCache";
type UIElementCollection = Record<string, UIElement>;
type UIElement = {
label: string,
key: string,
description: string,
childrenElements?: UIElementCollection,
isConfiguration?: true
}
export class GroupConfigurationRenderer {
private static MOVETO_COMMAND = 'moveto_';
private static SETVALUE_COMMAND = 'setvalue_';
private static MOVEBACK_COMMAND = 'back';
private breadcrumbs: string[] = [];
private static UI_ELEMENTS: UIElementCollection = {
channels: {
label: 'Channels',
key: 'channels',
description: "Provides settings to define in what channels the bot sends messages, when not directly interacting with it.",
childrenElements: {
newPlaydates: {
label: 'New Playdates',
key: 'newPlaydates',
description: "Sets the channel, where the group get notified when new Playdates are set.",
isConfiguration: true
},
playdateReminders: {
label: 'Playdate Reminders',
key: 'playdateReminders',
description: "Sets the channel, where the group gets reminded of upcoming playdates.",
isConfiguration: true
}
}
},
locale: {
label: "Locale",
key: 'locale',
description: "Provides locale to be used for this group. This mostly sets how the dates are displayed, but this can also be later used for translations.",
isConfiguration: true
},
permissions: {
label: "Permissions",
key: "permissions",
description: "Allows customization, how the members are allowed to interact with the data stored in the group.",
childrenElements: {
allowMemberManagingPlaydates: {
label: "Manage Playdates",
key: "allowMemberManagingPlaydates",
description: "Defines if the members are allowed to manage playdates like adding or deleting them.",
isConfiguration: true
}
}
}
}
constructor(
private readonly configurationHandler: GroupConfigurationHandler,
private readonly transformers: GroupConfigurationTransformers,
) {
}
public async setup(interaction: ChatInputCommandInteraction) {
let response = await interaction.reply(this.getReplyOptions());
let exit = false;
let eventResponse;
const filter = (i: Interaction) => i.user.id === interaction.user.id;
do {
if (eventResponse) {
response = await eventResponse.update(this.getReplyOptions());
}
try {
eventResponse = await response.resource?.message?.awaitMessageComponent({
dispose: true,
filter: filter,
time: 60_000
});
} catch (_: unknown) {
break;
}
if (!eventResponse || eventResponse.customId === 'exit') {
exit = true;
continue;
}
if (eventResponse.customId === GroupConfigurationRenderer.MOVEBACK_COMMAND) {
this.breadcrumbs.pop()
continue;
}
if (eventResponse.customId.startsWith(GroupConfigurationRenderer.MOVETO_COMMAND)) {
this.breadcrumbs.push(
eventResponse.customId.substring(GroupConfigurationRenderer.MOVETO_COMMAND.length)
)
continue;
}
if (eventResponse.customId.startsWith(GroupConfigurationRenderer.SETVALUE_COMMAND)) {
this.handleSelection(<AnySelectMenuInteraction>eventResponse);
continue;
}
} while (!exit);
if (eventResponse) {
try {
await eventResponse.update(
this.getReplyOptions()
);
} catch (_) {
}
await eventResponse.deleteReply();
return;
}
const message = response.resource?.message
if (!message) {
return;
}
if (message.deletable) {
await message.delete()
}
}
private getReplyOptions(): InteractionUpdateOptions & InteractionReplyOptions & { withResponse: true } {
const embed = this.createEmbed();
const icons = Container.get<IconCache>(IconCache.name);
embed.setAuthor({
name: "/ " + this.breadcrumbs.join(" / ")
});
const exitButton = new ButtonBuilder()
.setLabel("Exit")
.setStyle(ButtonStyle.Danger)
.setCustomId("exit")
.setEmoji(icons.get("door_open_solid_white") ?? '');
const actionrow = new ActionRowBuilder<ButtonBuilder>()
if (this.breadcrumbs.length > 0) {
const backButton = new ButtonBuilder()
.setLabel("Back")
.setStyle(ButtonStyle.Secondary)
.setCustomId(GroupConfigurationRenderer.MOVEBACK_COMMAND)
.setEmoji(icons.get("angle_left_solid") ?? '');
actionrow.addComponents(backButton)
}
actionrow.addComponents(exitButton)
return {
content: "",
embeds: [embed],
components: [...this.createActionRowBuildersForMenu(), actionrow],
withResponse: true,
flags: MessageFlags.Ephemeral
};
}
private createEmbed(): EmbedBuilder {
const {currentElement} = this.findCurrentUI();
if (currentElement === null) {
return new EmbedBuilder()
.setTitle("Group Configuration")
.setDescription("This UI allows you to change settings for your group.")
}
const embed = new EmbedBuilder()
.setTitle(currentElement?.label ?? '')
.setDescription(currentElement?.description ?? '');
if (currentElement?.isConfiguration ?? false) {
embed.addFields(
{name: "Current Value", value: this.getCurrentValueAsUI(), inline: false}
)
}
return embed;
}
private getCurrentValueAsUI(): string {
const path = this.breadcrumbs.join(".");
const value = this.configurationHandler.getConfigurationByPath(path);
if (value === undefined) return italic("None");
const type = this.transformers.getTransformerType(path);
if (type === undefined) {
throw new Error("Could not find the type for " + path);
}
const displaynames = new Intl.DisplayNames(["en"], {type: "language"});
switch (type) {
case TransformerType.Locale:
if (!value) {
return inlineCode("Default");
}
return displaynames.of((<Intl.Locale>value)?.baseName) ?? "Unknown";
case TransformerType.Channel:
if (!value) {
return inlineCode("None");
}
return channelMention(<ChannelId>value);
case TransformerType.PermissionBoolean:
return value ? "Allowed" : "Disallowed"
default:
return "None";
}
}
private createActionRowBuildersForMenu(): ActionRowBuilder<MessageActionRowComponentBuilder>[] {
const {currentCollection, currentElement} = this.findCurrentUI();
const icons = Container.get<IconCache>(IconCache.name);
if (currentElement?.isConfiguration ?? false) {
return [
new ActionRowBuilder<ChannelSelectMenuBuilder | MentionableSelectMenuBuilder | RoleSelectMenuBuilder | StringSelectMenuBuilder | UserSelectMenuBuilder>()
.addComponents(this.getSelectForBreadcrumbs())
]
}
return [
new ActionRowBuilder<ButtonBuilder>()
.setComponents(
...Object.values(currentCollection).map(elem => new ButtonBuilder()
.setLabel(` ${elem.label}`)
.setStyle(ButtonStyle.Primary)
.setCustomId(GroupConfigurationRenderer.MOVETO_COMMAND + elem.key)
.setEmoji(icons.get(elem.isConfiguration ? 'pen_solid' : "folder_solid") ?? '')
)
)
]
}
private getSelectForBreadcrumbs(): ChannelSelectMenuBuilder | MentionableSelectMenuBuilder | RoleSelectMenuBuilder | StringSelectMenuBuilder | UserSelectMenuBuilder {
const breadcrumbPath = this.breadcrumbs.join('.')
const transformerType = this.transformers.getTransformerType(breadcrumbPath);
if (transformerType === undefined) {
throw new Error(`Can not find transformer type for ${breadcrumbPath}`)
}
switch (transformerType) {
case TransformerType.Locale:
const options = [
'en-US',
'fr-FR',
'it-IT',
'de-DE'
]
const displaynames = new Intl.DisplayNames(["en"], {type: "language"});
return new StringSelectMenuBuilder()
.setCustomId(GroupConfigurationRenderer.SETVALUE_COMMAND + breadcrumbPath)
.setOptions(
options.map(intl => new StringSelectMenuOptionBuilder()
.setLabel(displaynames.of(intl) ?? '')
.setValue(intl)
)
)
case TransformerType.Channel:
return new ChannelSelectMenuBuilder()
.setCustomId(GroupConfigurationRenderer.SETVALUE_COMMAND + breadcrumbPath)
.setChannelTypes(ChannelType.GuildText)
.setPlaceholder("New Value");
case TransformerType.PermissionBoolean:
return new StringSelectMenuBuilder()
.setCustomId(GroupConfigurationRenderer.SETVALUE_COMMAND + breadcrumbPath)
.setOptions(
[
{
label: "Allow",
value: "1"
},
{
label: "Disallow",
value: "0"
}
]
)
default:
return new StringSelectMenuBuilder()
.setCustomId("...")
.setOptions(
new StringSelectMenuOptionBuilder()
.setLabel("Nothing to see here")
.setValue("0")
)
}
}
private handleSelection(interaction: AnySelectMenuInteraction) {
const path = interaction.customId.substring(GroupConfigurationRenderer.SETVALUE_COMMAND.length);
const savingValue = this.getSaveValue(interaction, path);
Container.get<Logger>("logger").debug(`Saving '${savingValue}' to '${path}'`);
this.configurationHandler.saveConfiguration(path, savingValue);
}
private getSaveValue(interaction: AnySelectMenuInteraction, path: string): string {
const transformerType = this.transformers.getTransformerType(path);
if (transformerType === undefined || transformerType === null) {
throw new Error(`Can not find transformer type for ${path}`)
}
switch (transformerType) {
case TransformerType.Locale:
case TransformerType.Channel:
case TransformerType.PermissionBoolean:
return interaction.values.join('; ');
default:
throw new Error("Unhandled select menu");
}
}
private findCurrentUI(): { currentElement: Nullable<UIElement>, currentCollection: UIElementCollection } {
let currentCollection: UIElementCollection = GroupConfigurationRenderer.UI_ELEMENTS;
let currentElement: Nullable<UIElement> = null;
for (const breadcrumb of this.breadcrumbs) {
currentElement = currentCollection[breadcrumb];
if (currentElement.isConfiguration ?? false) {
break;
}
currentCollection = currentElement.childrenElements ?? {};
}
return {
currentElement,
currentCollection,
}
}
}