From 25edfeaa24d39c27a670210bbf0e62e3d5dfa22b Mon Sep 17 00:00:00 2001 From: heyitsaamir Date: Mon, 6 Apr 2026 22:43:45 -0700 Subject: [PATCH 1/2] feat(adapter-teams): add Select and RadioSelect support in card Actions Map Select and RadioSelect card elements to Teams Adaptive Card ChoiceSetInput (compact and expanded styles). Auto-inject a submit button when inputs exist without explicit buttons, and fan out the auto-submit payload into individual onAction calls per input. Co-Authored-By: Claude --- examples/nextjs-chat/src/lib/bot.tsx | 38 ++++++++ packages/adapter-teams/src/cards.test.ts | 112 +++++++++++++++++++++++ packages/adapter-teams/src/cards.ts | 94 +++++++++++++++++-- packages/adapter-teams/src/index.ts | 69 +++++++++++++- 4 files changed, 302 insertions(+), 11 deletions(-) diff --git a/examples/nextjs-chat/src/lib/bot.tsx b/examples/nextjs-chat/src/lib/bot.tsx index be401533..17bfda99 100644 --- a/examples/nextjs-chat/src/lib/bot.tsx +++ b/examples/nextjs-chat/src/lib/bot.tsx @@ -117,6 +117,7 @@ bot.onNewMention(async (thread, message) => { + @@ -310,6 +311,43 @@ bot.onAction("plan_selected", (event) => { ); }); +bot.onAction("preferences", (event) => { + if (!event.thread) { + return; + } + event.thread.post( + + Choose your theme and notification settings: + + + + + + + + + + ); +}); + +bot.onAction("theme_selected", (event) => { + if (!event.thread) { + return; + } + event.thread.post(`${emoji.sparkles} Theme set to **${event.value}**`); +}); + +bot.onAction("notifications_selected", (event) => { + if (!event.thread) { + return; + } + event.thread.post(`${emoji.bell} Notifications set to **${event.value}**`); +}); + // Handle card button actions bot.onAction("hello", async (event) => { if (!event.thread) { diff --git a/packages/adapter-teams/src/cards.test.ts b/packages/adapter-teams/src/cards.test.ts index d1cbe8db..a270333c 100644 --- a/packages/adapter-teams/src/cards.test.ts +++ b/packages/adapter-teams/src/cards.test.ts @@ -9,7 +9,10 @@ import { Fields, Image, LinkButton, + RadioSelect, Section, + Select, + SelectOption, } from "chat"; import { describe, expect, it } from "vitest"; import { cardToAdaptiveCard, cardToFallbackText } from "./cards"; @@ -323,6 +326,115 @@ describe("cardToAdaptiveCard with modal buttons", () => { }); }); +describe("cardToAdaptiveCard with select and radio_select in Actions", () => { + it("converts Select to compact ChoiceSetInput in body", () => { + const card = Card({ + children: [ + Actions([ + Select({ + id: "color", + label: "Pick a color", + options: [ + SelectOption({ label: "Red", value: "red" }), + SelectOption({ label: "Blue", value: "blue" }), + ], + placeholder: "Choose...", + }), + ]), + ], + }); + const adaptive = cardToAdaptiveCard(card); + + expect(adaptive.body).toHaveLength(1); + expect(adaptive.body[0]).toMatchObject({ + type: "Input.ChoiceSet", + id: "color", + label: "Pick a color", + style: "compact", + isRequired: true, + placeholder: "Choose...", + }); + const choiceSet = adaptive.body[0] as { + choices: { title: string; value: string }[]; + }; + expect(choiceSet.choices).toHaveLength(2); + expect(choiceSet.choices[0]).toMatchObject({ + title: "Red", + value: "red", + }); + + // Auto-injects submit button since there are no explicit buttons + expect(adaptive.actions).toHaveLength(1); + expect(adaptive.actions?.[0]).toMatchObject({ + type: "Action.Submit", + title: "Submit", + data: { actionId: "__auto_submit" }, + }); + }); + + it("converts RadioSelect to expanded ChoiceSetInput in body", () => { + const card = Card({ + children: [ + Actions([ + RadioSelect({ + id: "plan", + label: "Choose Plan", + options: [ + SelectOption({ label: "Free", value: "free" }), + SelectOption({ label: "Pro", value: "pro" }), + ], + }), + ]), + ], + }); + const adaptive = cardToAdaptiveCard(card); + + expect(adaptive.body).toHaveLength(1); + expect(adaptive.body[0]).toMatchObject({ + type: "Input.ChoiceSet", + id: "plan", + label: "Choose Plan", + style: "expanded", + isRequired: true, + }); + + // Auto-injects submit button + expect(adaptive.actions).toHaveLength(1); + expect(adaptive.actions?.[0]).toMatchObject({ + type: "Action.Submit", + data: { actionId: "__auto_submit" }, + }); + }); + + it("does NOT auto-inject submit when buttons are present", () => { + const card = Card({ + children: [ + Actions([ + Select({ + id: "color", + label: "Color", + options: [SelectOption({ label: "Red", value: "red" })], + }), + Button({ id: "submit", label: "Submit", style: "primary" }), + ]), + ], + }); + const adaptive = cardToAdaptiveCard(card); + + // Select goes to body, button goes to actions + expect(adaptive.body).toHaveLength(1); + expect(adaptive.body[0]).toMatchObject({ + type: "Input.ChoiceSet", + id: "color", + }); + expect(adaptive.actions).toHaveLength(1); + expect(adaptive.actions?.[0]).toMatchObject({ + type: "Action.Submit", + title: "Submit", + }); + }); +}); + describe("cardToAdaptiveCard with CardLink", () => { it("converts CardLink to a TextBlock with markdown link", () => { const card = Card({ diff --git a/packages/adapter-teams/src/cards.ts b/packages/adapter-teams/src/cards.ts index 6712ecc2..46781751 100644 --- a/packages/adapter-teams/src/cards.ts +++ b/packages/adapter-teams/src/cards.ts @@ -14,10 +14,13 @@ import type { ActionArray, ActionStyle, CardElementArray, + ChoiceSetInputOptions, } from "@microsoft/teams.cards"; import { AdaptiveCard, Image as AdaptiveImage, + Choice, + ChoiceSetInput, Column, ColumnSet, Container, @@ -36,7 +39,9 @@ import type { FieldsElement, ImageElement, LinkButtonElement, + RadioSelectElement, SectionElement, + SelectElement, TableElement, TextElement, } from "chat"; @@ -51,6 +56,12 @@ const ADAPTIVE_CARD_SCHEMA = "http://adaptivecards.io/schemas/adaptive-card.json"; const ADAPTIVE_CARD_VERSION = "1.4" as const; +/** + * Sentinel action ID for auto-injected submit buttons. + * Used when a card has select/radio_select inputs but no submit button. + */ +export const AUTO_SUBMIT_ACTION_ID = "__auto_submit"; + /** * Convert a CardElement to a Teams Adaptive Card. */ @@ -176,17 +187,80 @@ function convertDividerToElement(_element: DividerElement): Container { } function convertActionsToElements(element: ActionsElement): ConvertResult { - // In Adaptive Cards, actions go at the card level, not inline - const actions: ActionArray = element.children - .filter((child) => child.type === "button" || child.type === "link-button") - .map((button) => { - if (button.type === "link-button") { - return convertLinkButtonToAction(button); - } - return convertButtonToAction(button); - }); + const actions: ActionArray = []; + const elements: CardElementArray = []; + let hasButtons = false; + let hasInputs = false; + + for (const child of element.children) { + switch (child.type) { + case "button": + hasButtons = true; + actions.push(convertButtonToAction(child)); + break; + case "link-button": + actions.push(convertLinkButtonToAction(child)); + break; + case "select": + hasInputs = true; + elements.push(convertSelectToElement(child)); + break; + case "radio_select": + hasInputs = true; + elements.push(convertRadioSelectToElement(child)); + break; + default: + break; + } + } + + // Auto-inject a submit button when there are inputs but no buttons. + // Teams inputs don't auto-submit like Slack — they need an Action.Submit. + if (hasInputs && !hasButtons) { + actions.push( + new SubmitAction({ + title: "Submit", + data: { actionId: AUTO_SUBMIT_ACTION_ID }, + }) + ); + } + + return { elements, actions }; +} + +function convertSelectToElement(select: SelectElement): ChoiceSetInput { + const choices = select.options.map( + (opt) => new Choice({ title: convertEmoji(opt.label), value: opt.value }) + ); + + const options: ChoiceSetInputOptions = { + id: select.id, + label: convertEmoji(select.label), + style: "compact", + isRequired: !(select.optional ?? false), + placeholder: select.placeholder, + value: select.initialOption, + }; + + return new ChoiceSetInput(...choices).withOptions(options); +} + +function convertRadioSelectToElement( + radioSelect: RadioSelectElement +): ChoiceSetInput { + const choices = radioSelect.options.map( + (opt) => new Choice({ title: convertEmoji(opt.label), value: opt.value }) + ); + + const options: ChoiceSetInputOptions = { + id: radioSelect.id, + label: convertEmoji(radioSelect.label), + style: "expanded", + isRequired: !(radioSelect.optional ?? false), + value: radioSelect.initialOption, + }; - return { elements: [], actions }; + return new ChoiceSetInput(...choices).withOptions(options); } function convertButtonToAction(button: ButtonElement): SubmitAction { diff --git a/packages/adapter-teams/src/index.ts b/packages/adapter-teams/src/index.ts index f847af90..89925d69 100644 --- a/packages/adapter-teams/src/index.ts +++ b/packages/adapter-teams/src/index.ts @@ -49,7 +49,7 @@ import { NotImplementedError, } from "chat"; import { BridgeHttpAdapter } from "./bridge-adapter"; -import { cardToAdaptiveCard } from "./cards"; +import { AUTO_SUBMIT_ACTION_ID, cardToAdaptiveCard } from "./cards"; import { toAppOptions } from "./config"; import { handleTeamsError } from "./errors"; import { TeamsGraphReader } from "./graph-api"; @@ -337,6 +337,16 @@ export class TeamsAdapter implements Adapter { serviceUrl: activity.serviceUrl || "", }); + // Auto-submit fan-out: fire onAction for each input value + if (actionValue.actionId === AUTO_SUBMIT_ACTION_ID) { + this.fanOutAutoSubmit( + actionValue as unknown as Record, + activity, + threadId + ); + return; + } + const actionEvent: Omit & { adapter: TeamsAdapter; } = { @@ -393,6 +403,13 @@ export class TeamsAdapter implements Adapter { serviceUrl: activity.serviceUrl || "", }); + // Auto-submit fan-out: fire onAction for each input value + if (actionData.actionId === AUTO_SUBMIT_ACTION_ID) { + const rawPayload = activity.value.action.data as Record; + this.fanOutAutoSubmit(rawPayload, activity, threadId); + return; + } + const actionEvent: Omit & { adapter: TeamsAdapter; } = { @@ -424,6 +441,56 @@ export class TeamsAdapter implements Adapter { ); } + /** + * Fan out an auto-submit payload into individual onAction calls. + * Called when the sentinel __auto_submit action ID is detected. + * Each input key/value pair is dispatched as a separate action in parallel. + */ + private fanOutAutoSubmit( + payload: Record, + activity: Activity, + threadId: string + ): void { + if (!this.chat) { + return; + } + + const webhookOptions = this.bridgeAdapter.getWebhookOptions(activity.id); + const entries = Object.entries(payload).filter( + ([key]) => key !== "actionId" && key !== "msteams" + ); + + this.logger.debug("Auto-submit fan-out", { + inputCount: entries.length, + keys: entries.map(([k]) => k), + }); + + const baseEvent = { + user: { + userId: activity.from?.id || "unknown", + userName: activity.from?.name || "unknown", + fullName: activity.from?.name || "unknown", + isBot: false, + isMe: false, + }, + messageId: activity.replyToId || activity.id || "", + threadId, + adapter: this as TeamsAdapter, + raw: activity, + }; + + for (const [key, val] of entries) { + this.chat.processAction( + { + ...baseEvent, + actionId: key, + value: typeof val === "string" ? val : undefined, + }, + webhookOptions + ); + } + } + /** * Handle dialog.open (task/fetch) invoke. * Uses Promise.race to resolve as soon as onOpenModal fires. From 05ed5d1ec5f5b459d4f9b3dbf4eed0f4dfb08d5d Mon Sep 17 00:00:00 2001 From: heyitsaamir Date: Tue, 7 Apr 2026 11:33:11 -0700 Subject: [PATCH 2/2] Add changeset for Teams select support Co-Authored-By: Claude --- .changeset/teams-select-support.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/teams-select-support.md diff --git a/.changeset/teams-select-support.md b/.changeset/teams-select-support.md new file mode 100644 index 00000000..965af6fd --- /dev/null +++ b/.changeset/teams-select-support.md @@ -0,0 +1,5 @@ +--- +"@chat-adapter/teams": minor +--- + +Add Select and RadioSelect support for Teams Adaptive Cards with auto-submit fan-out