diff --git a/.changeset/gchat-selection-inputs.md b/.changeset/gchat-selection-inputs.md new file mode 100644 index 00000000..316e563f --- /dev/null +++ b/.changeset/gchat-selection-inputs.md @@ -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. diff --git a/packages/adapter-gchat/src/cards.test.ts b/packages/adapter-gchat/src/cards.test.ts index 4ba64a4b..092499aa 100644 --- a/packages/adapter-gchat/src/cards.test.ts +++ b/packages/adapter-gchat/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 { cardToFallbackText, cardToGoogleCard } from "./cards"; @@ -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: [ diff --git a/packages/adapter-gchat/src/cards.ts b/packages/adapter-gchat/src/cards.ts index e6c0717b..8c28619d 100644 --- a/packages/adapter-gchat/src/cards.ts +++ b/packages/adapter-gchat/src/cards.ts @@ -18,7 +18,9 @@ import type { FieldsElement, ImageElement, LinkButtonElement, + RadioSelectElement, SectionElement, + SelectElement, TableElement, TextElement, } from "chat"; @@ -61,9 +63,27 @@ export interface GoogleChatWidget { }; divider?: Record; 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; @@ -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": @@ -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 }], + }, + }, }; } diff --git a/packages/adapter-gchat/src/index.test.ts b/packages/adapter-gchat/src/index.test.ts index 2549451c..50ff6fe2 100644 --- a/packages/adapter-gchat/src/index.test.ts +++ b/packages/adapter-gchat/src/index.test.ts @@ -1069,6 +1069,105 @@ describe("GoogleChatAdapter", () => { ); }); + it("should read selection values from formInputs when parameters.value is missing", async () => { + const { adapter, mockChat } = await createInitializedAdapter(); + const event: GoogleChatEvent = { + commonEventObject: { + parameters: { + actionId: "selection", + }, + formInputs: { + selection: { + stringInputs: { + value: ["option-1"], + }, + }, + }, + }, + chat: { + buttonClickedPayload: { + space: { name: "spaces/ABC123", type: "ROOM" }, + message: { + name: "spaces/ABC123/messages/msg1", + sender: { name: "users/1", displayName: "U", type: "HUMAN" }, + text: "", + createTime: new Date().toISOString(), + }, + user: { + name: "users/2", + displayName: "Clicker", + type: "HUMAN", + email: "", + }, + }, + }, + }; + const request = new Request("https://example.com/webhook", { + method: "POST", + body: JSON.stringify(event), + }); + + await adapter.handleWebhook(request); + + expect(mockChat.processAction).toHaveBeenCalledWith( + expect.objectContaining({ + actionId: "selection", + value: "option-1", + }), + undefined + ); + }); + + it("should prefer parameters.value when both parameters and formInputs are present", async () => { + const { adapter, mockChat } = await createInitializedAdapter(); + const event: GoogleChatEvent = { + commonEventObject: { + parameters: { + actionId: "selection", + value: "button-value", + }, + formInputs: { + selection: { + stringInputs: { + value: ["dropdown-value"], + }, + }, + }, + }, + chat: { + buttonClickedPayload: { + space: { name: "spaces/ABC123", type: "ROOM" }, + message: { + name: "spaces/ABC123/messages/msg1", + sender: { name: "users/1", displayName: "U", type: "HUMAN" }, + text: "", + createTime: new Date().toISOString(), + }, + user: { + name: "users/2", + displayName: "Clicker", + type: "HUMAN", + email: "", + }, + }, + }, + }; + const request = new Request("https://example.com/webhook", { + method: "POST", + body: JSON.stringify(event), + }); + + await adapter.handleWebhook(request); + + expect(mockChat.processAction).toHaveBeenCalledWith( + expect.objectContaining({ + actionId: "selection", + value: "button-value", + }), + undefined + ); + }); + it("should ignore card click when space is missing", async () => { const { adapter, mockChat } = await createInitializedAdapter(); const event: GoogleChatEvent = { diff --git a/packages/adapter-gchat/src/index.ts b/packages/adapter-gchat/src/index.ts index 488c674a..4fe82def 100644 --- a/packages/adapter-gchat/src/index.ts +++ b/packages/adapter-gchat/src/index.ts @@ -221,6 +221,14 @@ export interface GoogleChatUser { type: string; } +interface GoogleChatFormInput { + stringInputs?: { + value?: string[]; + }; +} + +type GoogleChatFormInputs = Record; + /** * Google Workspace Add-ons event format. * This is the format used when configuring the app via Google Cloud Console. @@ -249,6 +257,7 @@ export interface GoogleChatEvent { }; }; commonEventObject?: { + formInputs?: GoogleChatFormInputs; userLocale?: string; hostApp?: string; platform?: string; @@ -1117,8 +1126,11 @@ export class GoogleChatAdapter implements Adapter { return; } - // Get value from parameters - const value = commonEvent?.parameters?.value; + // Buttons send value via parameters, while selection inputs return + // the chosen option through formInputs under the action ID. + const value = + commonEvent?.parameters?.value ?? + this.getFormInputValue(commonEvent?.formInputs, actionId); // Get space and message info from buttonClickedPayload const space = buttonPayload?.space; @@ -1164,6 +1176,13 @@ export class GoogleChatAdapter implements Adapter { this.chat.processAction(actionEvent, options); } + private getFormInputValue( + formInputs: GoogleChatFormInputs | undefined, + actionId: string + ): string | undefined { + return formInputs?.[actionId]?.stringInputs?.value?.[0]; + } + /** * Handle direct webhook message events (Add-ons format). */