Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/teams-select-support.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@chat-adapter/teams": minor
---

Add Select and RadioSelect support for Teams Adaptive Cards with auto-submit fan-out
38 changes: 38 additions & 0 deletions examples/nextjs-chat/src/lib/bot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ bot.onNewMention(async (thread, message) => {
<Button id="ephemeral">Ephemeral response</Button>
<Button id="info">Show Info</Button>
<Button id="choose_plan">Choose Plan</Button>
<Button id="preferences">Preferences</Button>
<Button actionType="modal" id="feedback">
Send Feedback
</Button>
Expand Down Expand Up @@ -310,6 +311,43 @@ bot.onAction("plan_selected", (event) => {
);
});

bot.onAction("preferences", (event) => {
if (!event.thread) {
return;
}
event.thread.post(
<Card title="Set Preferences">
<Text>Choose your theme and notification settings:</Text>
<Actions>
<Select id="theme_selected" label="Theme" placeholder="Pick a theme...">
<SelectOption label="Light" value="light" />
<SelectOption label="Dark" value="dark" />
<SelectOption label="System" value="system" />
</Select>
<RadioSelect id="notifications_selected" label="Notifications">
<SelectOption label="All notifications" value="all" />
<SelectOption label="Mentions only" value="mentions" />
<SelectOption label="None" value="none" />
</RadioSelect>
</Actions>
</Card>
);
});

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) {
Expand Down
112 changes: 112 additions & 0 deletions packages/adapter-teams/src/cards.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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({
Expand Down
94 changes: 84 additions & 10 deletions packages/adapter-teams/src/cards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,13 @@ import type {
ActionArray,
ActionStyle,
CardElementArray,
ChoiceSetInputOptions,
} from "@microsoft/teams.cards";
import {
AdaptiveCard,
Image as AdaptiveImage,
Choice,
ChoiceSetInput,
Column,
ColumnSet,
Container,
Expand All @@ -36,7 +39,9 @@ import type {
FieldsElement,
ImageElement,
LinkButtonElement,
RadioSelectElement,
SectionElement,
SelectElement,
TableElement,
TextElement,
} from "chat";
Expand All @@ -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.
*/
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading