Skip to content

Commit 3dd7bd8

Browse files
authored
feat(walkthrough): rebuild the walkthrough with Components V2 (#57)
* feat(walkthrough): rebuild the walkthrough with components v2 * refactor(walkthrough): reduce noise in the components v2 builder * refactor(walkthrough): carry state in custom id instead of parsing text * refactor(walkthrough): write summary fields explicitly * feat(walkthrough): show answers as disabled emoji buttons * feat(walkthrough): split lifecycle text onto two lines * feat(walkthrough): shorten lifecycle close wording * feat(walkthrough): enable answer buttons as no-ops * feat(walkthrough): tell users the answer buttons can't be edited * feat(walkthrough): disable answer buttons until answered * feat(ui): add emojis to issue category options * feat(ui): use sparkles for feature request * refactor(walkthrough): extract optionOf and row helpers
1 parent 6ab26d7 commit 3dd7bd8

3 files changed

Lines changed: 205 additions & 156 deletions

File tree

src/commands/util/walkthrough.ts

Lines changed: 182 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -4,22 +4,27 @@ import { isHelpPost as isHelpThread } from "@lib/discord/channels.js";
44
import { getCommandMention } from "@lib/discord/commands.js";
55
import issueCategorySelector from "@components/issueCategorySelector.js";
66
import productSelector from "@components/productSelector.js";
7+
import operatingSystemFamilySelector from "@components/operatingSystemFamilySelector.js";
78

89
import {
910
ActionRowBuilder,
1011
ButtonBuilder,
1112
ButtonStyle,
13+
type ButtonInteraction,
1214
type ChatInputCommandInteraction,
1315
type Client,
1416
Colors,
15-
type Embed,
16-
EmbedBuilder,
17+
ContainerBuilder,
1718
type GuildTextBasedChannel,
1819
type MessageActionRowComponentBuilder,
1920
MessageFlags,
2021
type PublicThreadChannel,
22+
SectionBuilder,
23+
SeparatorBuilder,
2124
SlashCommandBuilder,
22-
type StringSelectMenuBuilder,
25+
StringSelectMenuBuilder,
26+
type StringSelectMenuInteraction,
27+
TextDisplayBuilder,
2328
} from "discord.js";
2429

2530
type ResourceLink = { label: string; url: string };
@@ -43,128 +48,210 @@ const productResources: Record<string, ResourceLink[]> = {
4348
],
4449
};
4550

46-
// Resolves the resources for a product from the label shown in the data embed.
47-
function resourcesForProduct(productLabel: string): ResourceLink[] {
48-
const option = productSelector.options.find(
49-
(o) => o.data.label === productLabel,
51+
// The walkthrough asks one selector per field, in this order.
52+
const steps = [
53+
{
54+
field: "Category",
55+
menu: issueCategorySelector,
56+
prompt: () => "What are you creating this issue for?",
57+
},
58+
{
59+
field: "Product",
60+
menu: productSelector,
61+
prompt: () => "What product are you using?",
62+
},
63+
{
64+
field: "Platform",
65+
menu: operatingSystemFamilySelector,
66+
prompt: (product: string) =>
67+
`What operating system are you running ${product} on?`,
68+
},
69+
] as const;
70+
71+
// Answered values are carried between steps in the selector's custom id, so the
72+
// walkthrough never has to read its state back out of the message.
73+
const CUSTOM_ID = "walkthrough";
74+
75+
const text = (content: string) => new TextDisplayBuilder({ content });
76+
77+
const optionOf = (menu: StringSelectMenuBuilder, value?: string) =>
78+
menu.options.find((o) => o.data.value === value)?.data;
79+
80+
const row = (...components: MessageActionRowComponentBuilder[]) =>
81+
new ActionRowBuilder<MessageActionRowComponentBuilder>().addComponents(
82+
...components,
5083
);
51-
return (option && productResources[option.data.value ?? ""]) || [];
52-
}
5384

54-
// The data embed tracks the walkthrough answers. Its fields line up with the
55-
// walkthrough selectors (Category, Product, Platform) so each step fills the
56-
// matching field in place.
57-
export function buildDataEmbed(channelId: string) {
58-
return new EmbedBuilder().setTitle(`<#${channelId}>`).addFields([
59-
{ name: "Category", value: "N/A", inline: true },
60-
{ name: "Product", value: "N/A", inline: true },
61-
{ name: "Platform", value: "N/A", inline: true },
62-
{ name: "Logs", value: "Please post any relevant logs/error messages." },
63-
]);
85+
// A field row: the field name with a disabled button showing the chosen option
86+
// (label and emoji), or "N/A" until it is answered.
87+
function fieldSection(
88+
field: string,
89+
menu: StringSelectMenuBuilder,
90+
value?: string,
91+
) {
92+
const option = optionOf(menu, value);
93+
94+
const button = new ButtonBuilder()
95+
.setStyle(ButtonStyle.Secondary)
96+
.setCustomId(`${CUSTOM_ID}:field:${field}`)
97+
.setDisabled(!option)
98+
.setLabel(option?.label ?? "N/A");
99+
100+
if (option?.emoji) {
101+
button.setEmoji(option.emoji);
102+
}
103+
104+
return new SectionBuilder()
105+
.addTextDisplayComponents(text(field))
106+
.setButtonAccessory(button);
64107
}
65108

66-
// The resources embed points users at the post lifecycle commands. It stays the
67-
// same for the whole walkthrough.
68-
export async function buildResourcesEmbed(client: Client) {
69-
return new EmbedBuilder()
70-
.setColor(Colors.White)
71-
.setDescription(
72-
`When your issue is resolved, use ${await getCommandMention(client, "close")} to close this issue. Use ${await getCommandMention(client, "reopen")} to reopen it if needed.`,
73-
);
109+
async function lifecycleText(client: Client) {
110+
const close = await getCommandMention(client, "close");
111+
const reopen = await getCommandMention(client, "reopen");
112+
return `When your issue is resolved, use ${close} to close it.\nUse ${reopen} to reopen it if needed.`;
74113
}
75114

76-
// Assembles the single walkthrough message from its current state: the data and
77-
// resources embeds, the current question and selector (while the walkthrough is
78-
// running), and a documentation button per resource of the selected product.
79-
export function buildWalkthroughMessage(
80-
dataEmbed: EmbedBuilder,
81-
resourcesEmbed: EmbedBuilder | Embed,
82-
step?: { question: string; selector: StringSelectMenuBuilder },
115+
// Builds the walkthrough message from the answered values so far: an info
116+
// container with a field row per answer, the current question and selector while
117+
// steps remain, and the selected product's documentation buttons at the bottom.
118+
async function buildMessage(
119+
client: Client,
120+
channelId: string,
121+
values: string[],
83122
) {
84-
const embeds: (EmbedBuilder | Embed)[] = [dataEmbed, resourcesEmbed];
85-
const components: ActionRowBuilder<MessageActionRowComponentBuilder>[] = [];
123+
const info = new ContainerBuilder()
124+
.setAccentColor(Colors.Blurple)
125+
.addTextDisplayComponents(text(`<#${channelId}>`))
126+
.addSeparatorComponents(new SeparatorBuilder())
127+
.addSectionComponents(
128+
fieldSection("Category", issueCategorySelector, values[0]),
129+
fieldSection("Product", productSelector, values[1]),
130+
fieldSection("Platform", operatingSystemFamilySelector, values[2]),
131+
)
132+
.addSeparatorComponents(new SeparatorBuilder())
133+
.addTextDisplayComponents(text(await lifecycleText(client)));
134+
135+
const components: (
136+
| ContainerBuilder
137+
| ActionRowBuilder<MessageActionRowComponentBuilder>
138+
)[] = [info];
86139

140+
const step = steps[values.length];
87141
if (step) {
88-
embeds.push(
89-
new EmbedBuilder().setColor(Colors.White).setDescription(step.question),
90-
);
142+
const product = optionOf(productSelector, values[1])?.label ?? "N/A";
143+
91144
components.push(
92-
new ActionRowBuilder<MessageActionRowComponentBuilder>().addComponents(
93-
step.selector,
145+
new ContainerBuilder()
146+
.setAccentColor(Colors.Blurple)
147+
.addTextDisplayComponents(text(step.prompt(product))),
148+
row(
149+
StringSelectMenuBuilder.from(step.menu).setCustomId(
150+
[CUSTOM_ID, ...values].join(":"),
151+
),
94152
),
95153
);
96154
}
97155

98-
const resources = resourcesForProduct(
99-
dataEmbed.data.fields?.[1]?.value ?? "",
100-
);
101-
if (resources.length > 0) {
156+
const docs = productResources[values[1]] ?? [];
157+
if (docs.length > 0) {
102158
components.push(
103-
new ActionRowBuilder<MessageActionRowComponentBuilder>().addComponents(
104-
resources.map((resource) =>
159+
row(
160+
...docs.map((doc) =>
105161
new ButtonBuilder()
106162
.setStyle(ButtonStyle.Link)
107-
.setLabel(resource.label)
108-
.setURL(resource.url),
163+
.setLabel(doc.label)
164+
.setURL(doc.url),
109165
),
110166
),
111167
);
112168
}
113169

114-
return { embeds, components };
170+
return { flags: MessageFlags.IsComponentsV2 as const, components };
115171
}
116172

117173
export async function doWalkthrough(
118174
channel: GuildTextBasedChannel,
119175
interaction?: ChatInputCommandInteraction,
120176
) {
121-
if (await isHelpThread(channel)) {
122-
const threadChannel = channel as PublicThreadChannel; // necessary type cast, isHelpThread does the check already
123-
124-
// Check for tags in the forum post
125-
const appliedTags = threadChannel.appliedTags ?? [];
126-
if (!appliedTags.includes(config.helpChannel.openedTag)) {
127-
appliedTags.push(config.helpChannel.openedTag);
128-
threadChannel.setAppliedTags(appliedTags);
129-
}
130-
131-
const walkthroughMessage = buildWalkthroughMessage(
132-
buildDataEmbed(channel.id),
133-
await buildResourcesEmbed(channel.client),
134-
{
135-
question: "What are you creating this issue for?",
136-
selector: issueCategorySelector,
137-
},
177+
if (!(await isHelpThread(channel))) {
178+
return;
179+
}
180+
181+
const threadChannel = channel as PublicThreadChannel; // necessary type cast, isHelpThread does the check already
182+
183+
// Check for tags in the forum post
184+
const appliedTags = threadChannel.appliedTags ?? [];
185+
if (!appliedTags.includes(config.helpChannel.openedTag)) {
186+
appliedTags.push(config.helpChannel.openedTag);
187+
threadChannel.setAppliedTags(appliedTags);
188+
}
189+
190+
const walkthroughMessage = await buildMessage(channel.client, channel.id, []);
191+
192+
// Slash-command runs reply to the user; auto-runs post to the thread.
193+
if (!interaction) {
194+
await channel.send(walkthroughMessage);
195+
return;
196+
}
197+
198+
// If the bot already posted a walkthrough (a message with components) near the
199+
// start of the thread, don't post another one.
200+
const firstMessage = await threadChannel.fetchStarterMessage();
201+
const existing = await threadChannel.messages
202+
.fetch({ around: firstMessage.id, limit: 30 })
203+
.then((messages) =>
204+
messages
205+
.filter(
206+
(message) =>
207+
message.author.id === interaction.client.user.id &&
208+
message.components.length > 0,
209+
)
210+
.at(0),
138211
);
139212

140-
// Send the walkthrough message (or reply to the user if they're running the command)
141-
if (interaction) {
142-
// If the bot has sent a message that contains an embed in the first 30 messages, then we assume it's the walkthrough message
143-
const firstMessage = await threadChannel.fetchStarterMessage();
144-
const existingWalkthrough = await threadChannel.messages
145-
.fetch({ around: firstMessage.id, limit: 30 })
146-
.then((messages) =>
147-
messages
148-
.filter(
149-
(message) =>
150-
message.author.id === interaction.client.user.id &&
151-
message.embeds.length > 0,
152-
)
153-
.at(0),
154-
);
155-
156-
if (existingWalkthrough) {
157-
await interaction.reply({
158-
content: `You cannot run the walkthrough command because a walkthrough already exists in this channel.\n(${existingWalkthrough.url})`,
159-
flags: MessageFlags.Ephemeral,
160-
});
161-
return;
162-
}
163-
164-
await interaction.reply(walkthroughMessage);
165-
} else {
166-
await channel.send(walkthroughMessage);
167-
}
213+
if (existing) {
214+
await interaction.reply({
215+
content: `You cannot run the walkthrough command because a walkthrough already exists in this channel.\n(${existing.url})`,
216+
flags: MessageFlags.Ephemeral,
217+
});
218+
return;
219+
}
220+
221+
await interaction.reply(walkthroughMessage);
222+
}
223+
224+
// Advances the walkthrough one step by editing the same message with the newly
225+
// answered value appended.
226+
export async function handleSelection(
227+
interaction: StringSelectMenuInteraction,
228+
) {
229+
if (!interaction.customId.startsWith(CUSTOM_ID)) {
230+
return;
231+
}
232+
233+
const values = [
234+
...interaction.customId.split(":").slice(1),
235+
interaction.values[0],
236+
];
237+
238+
await interaction.update(
239+
await buildMessage(interaction.client, interaction.channelId, values),
240+
);
241+
242+
if (values.length === steps.length) {
243+
await interaction.message.pin();
244+
}
245+
}
246+
247+
// The answer buttons only summarize the walkthrough answers, so a click just
248+
// tells the user they can't be edited.
249+
export async function handleFieldButton(interaction: ButtonInteraction) {
250+
if (interaction.customId.startsWith(`${CUSTOM_ID}:field:`)) {
251+
await interaction.reply({
252+
content: "This is just a summary of your answers, you can't edit it.",
253+
flags: MessageFlags.Ephemeral,
254+
});
168255
}
169256
}
170257

0 commit comments

Comments
 (0)