Skip to content
Merged
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/gchat-selection-inputs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@chat-adapter/gchat": patch
---

Support `Select` and `RadioSelect` card actions in Google Chat by rendering them as `selectionInput` widgets and reading selected values from form inputs on action events.
155 changes: 155 additions & 0 deletions packages/adapter-gchat/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 { cardToFallbackText, cardToGoogleCard } from "./cards";
Expand Down Expand Up @@ -295,6 +298,158 @@ describe("cardToGoogleCard", () => {
});
});

it("converts select actions to selectionInput dropdown widgets", () => {
const card = Card({
children: [
Actions([
Select({
id: "priority",
label: "Priority",
options: [
SelectOption({
label: "High",
value: "high",
description: "Urgent",
}),
SelectOption({ label: "Normal", value: "normal" }),
],
initialOption: "normal",
}),
]),
],
});

const gchatCard = cardToGoogleCard(card, {
endpointUrl: "https://example.com/api/webhooks/gchat",
});

const widgets = gchatCard.card.sections[0].widgets;
expect(widgets).toHaveLength(1);
expect(widgets[0]).toEqual({
selectionInput: {
name: "priority",
label: "Priority",
type: "DROPDOWN",
items: [
{
text: "High",
value: "high",
},
{
text: "Normal",
value: "normal",
selected: true,
},
],
onChangeAction: {
function: "https://example.com/api/webhooks/gchat",
parameters: [{ key: "actionId", value: "priority" }],
},
},
});
});

it("converts radio select actions to selectionInput radio widgets", () => {
const card = Card({
children: [
Actions([
RadioSelect({
id: "status",
label: "Status",
options: [
SelectOption({ label: "Open", value: "open" }),
SelectOption({ label: "Closed", value: "closed" }),
],
initialOption: "open",
}),
]),
],
});

const gchatCard = cardToGoogleCard(card);
const widgets = gchatCard.card.sections[0].widgets;

expect(widgets).toHaveLength(1);
expect(widgets[0]).toEqual({
selectionInput: {
name: "status",
label: "Status",
type: "RADIO_BUTTON",
items: [
{
text: "Open",
value: "open",
selected: true,
},
{
text: "Closed",
value: "closed",
},
],
onChangeAction: {
function: "status",
parameters: [{ key: "actionId", value: "status" }],
},
},
});
});

it("preserves action order for mixed buttons and selection inputs", () => {
const card = Card({
children: [
Actions([
Button({ id: "refresh", label: "Refresh" }),
Select({
id: "category",
label: "Category",
options: [
SelectOption({ label: "Alpha", value: "alpha" }),
SelectOption({ label: "Beta", value: "beta" }),
],
}),
LinkButton({ url: "https://example.com/docs", label: "Docs" }),
RadioSelect({
id: "view",
label: "View",
options: [
SelectOption({ label: "Summary", value: "summary" }),
SelectOption({ label: "Detailed", value: "detailed" }),
],
}),
]),
],
});

const gchatCard = cardToGoogleCard(card);
const widgets = gchatCard.card.sections[0].widgets;

expect(widgets).toHaveLength(4);
expect(widgets[0].buttonList?.buttons).toHaveLength(1);
expect(widgets[0].buttonList?.buttons[0]).toEqual({
text: "Refresh",
onClick: {
action: {
function: "refresh",
parameters: [{ key: "actionId", value: "refresh" }],
},
},
});
expect(widgets[1].selectionInput?.name).toBe("category");
expect(widgets[1].selectionInput?.type).toBe("DROPDOWN");
expect(widgets[2].buttonList?.buttons).toEqual([
{
text: "Docs",
onClick: {
openLink: {
url: "https://example.com/docs",
},
},
},
]);
expect(widgets[3].selectionInput?.name).toBe("view");
expect(widgets[3].selectionInput?.type).toBe("RADIO_BUTTON");
});

it("converts fields to decoratedText widgets", () => {
const card = Card({
children: [
Expand Down
95 changes: 84 additions & 11 deletions packages/adapter-gchat/src/cards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ import type {
FieldsElement,
ImageElement,
LinkButtonElement,
RadioSelectElement,
SectionElement,
SelectElement,
TableElement,
TextElement,
} from "chat";
Expand Down Expand Up @@ -61,9 +63,27 @@ export interface GoogleChatWidget {
};
divider?: Record<string, never>;
image?: { imageUrl: string; altText?: string };
selectionInput?: GoogleChatSelectionInput;
textParagraph?: { text: string };
}

export interface GoogleChatSelectionInput {
items: GoogleChatSelectionItem[];
label: string;
name: string;
onChangeAction: {
function: string;
parameters: Array<{ key: string; value: string }>;
};
type: "DROPDOWN" | "RADIO_BUTTON";
}

export interface GoogleChatSelectionItem {
selected?: boolean;
text: string;
value: string;
}

export interface GoogleChatButton {
color?: { red: number; green: number; blue: number };
disabled?: boolean;
Expand Down Expand Up @@ -192,7 +212,7 @@ function convertChildToWidgets(
case "divider":
return [convertDividerToWidget(child)];
case "actions":
return [convertActionsToWidget(child, endpointUrl)];
return convertActionsToWidgets(child, endpointUrl);
case "section":
return convertSectionToWidgets(child, endpointUrl);
case "fields":
Expand Down Expand Up @@ -252,21 +272,74 @@ function convertDividerToWidget(_element: DividerElement): GoogleChatWidget {
return { divider: {} };
}

function convertActionsToWidget(
function convertActionsToWidgets(
element: ActionsElement,
endpointUrl?: string
): GoogleChatWidget {
const buttons: (GoogleChatButton | GoogleChatLinkButton)[] = element.children
.filter((child) => child.type === "button" || child.type === "link-button")
.map((button) => {
if (button.type === "link-button") {
return convertLinkButtonToGoogleButton(button);
}
return convertButtonToGoogleButton(button, endpointUrl);
): GoogleChatWidget[] {
const widgets: GoogleChatWidget[] = [];
let buttons: (GoogleChatButton | GoogleChatLinkButton)[] = [];

const flushButtons = () => {
if (buttons.length === 0) {
return;
}

widgets.push({
buttonList: { buttons },
});
buttons = [];
};

for (const child of element.children) {
if (child.type === "button") {
buttons.push(convertButtonToGoogleButton(child, endpointUrl));
continue;
}

if (child.type === "link-button") {
buttons.push(convertLinkButtonToGoogleButton(child));
continue;
}

if (child.type === "select" || child.type === "radio_select") {
flushButtons();
widgets.push(convertSelectionInputToWidget(child, endpointUrl));
}
}

flushButtons();

return widgets;
}

function convertSelectionInputToWidget(
element: SelectElement | RadioSelectElement,
endpointUrl?: string
): GoogleChatWidget {
const items = element.options.map((option) => {
const item: GoogleChatSelectionItem = {
text: convertEmoji(option.label),
value: option.value,
};

if (option.value === element.initialOption) {
item.selected = true;
}

return item;
});

return {
buttonList: { buttons },
selectionInput: {
name: element.id,
label: convertEmoji(element.label),
type: element.type === "radio_select" ? "RADIO_BUTTON" : "DROPDOWN",
items,
onChangeAction: {
function: endpointUrl || element.id,
parameters: [{ key: "actionId", value: element.id }],
},
},
};
}

Expand Down
Loading
Loading