Skip to content

Commit 4da4b99

Browse files
fix(gchat): clear cardsV2 on edit-to-text + Select/RadioSelect selectionInput parity
Two pre-existing Google Chat parity gaps ported faithfully from upstream chat@4.30.0 (packages/adapter-gchat). (A) edit-to-text must clear cardsV2 [LIVE BUG] Editing a card message down to plain text left the original card stranded: GChat renders both text and cardsV2 when the card field is untouched. The text-edit path now sends updateMask "text,cardsV2" with an empty cardsV2 list, matching upstream index.ts spaces.messages.update (text branch). Regression test asserts the edit body clears cardsV2. (B) Select/RadioSelect -> selectionInput widgets + read formInputs - Render half (cards.py): _convert_actions_to_widget became the list-returning _convert_actions_to_widgets, interleaving DROPDOWN / RADIO_BUTTON selectionInput widgets (with onChangeAction) between flushed buttonList widgets in source order. Ported from cards.ts convertActionsToWidgets + convertSelectionInputToWidget. - Read half (adapter.py): card-click value now falls back to formInputs[actionId].stringInputs.value[0] when parameters.value is absent, preferring parameters.value when both are present. Ported from index.ts getFormInputValue + handleCardClick. Tests: selectionInput rendering (dropdown/radio/mixed-order/no-initial), formInputs value path (read-from-formInputs + prefer-parameters), and the cardsV2-clear regression. All fail on the pre-fix code.
1 parent 17aa41a commit 4da4b99

5 files changed

Lines changed: 361 additions & 11 deletions

File tree

src/chat_sdk/adapters/google_chat/adapter.py

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1290,8 +1290,11 @@ def _handle_card_click(
12901290
)
12911291
return
12921292

1293-
# Get value from parameters
1293+
# Buttons send value via parameters, while selection inputs return the
1294+
# chosen option through formInputs under the action ID.
12941295
value = (common_event.get("parameters") or {}).get("value")
1296+
if value is None:
1297+
value = self._get_form_input_value(common_event.get("formInputs"), action_id)
12951298

12961299
# Get space and message info
12971300
space = (button_payload or {}).get("space")
@@ -1339,6 +1342,20 @@ def _handle_card_click(
13391342

13401343
self._chat.process_action(action_event, options)
13411344

1345+
def _get_form_input_value(
1346+
self,
1347+
form_inputs: dict[str, Any] | None,
1348+
action_id: str,
1349+
) -> str | None:
1350+
"""Resolve a selection-input value from a card-click ``formInputs`` map.
1351+
1352+
Mirrors upstream ``getFormInputValue``: returns
1353+
``formInputs[actionId].stringInputs.value[0]`` if present, else ``None``.
1354+
"""
1355+
entry = (form_inputs or {}).get(action_id) or {}
1356+
values = (entry.get("stringInputs") or {}).get("value") or []
1357+
return values[0] if values else None
1358+
13421359
def _handle_message_event(
13431360
self,
13441361
event: dict[str, Any],
@@ -1678,8 +1695,11 @@ async def edit_message(
16781695
response = await self._gchat_api_request(
16791696
"PATCH",
16801697
message_id,
1681-
body={"text": text},
1682-
params={"updateMask": "text"},
1698+
# Clear any stranded card when editing a card message down to
1699+
# plain text. GChat renders both text and cardsV2 if cardsV2 is
1700+
# left untouched, so send an empty list under the update mask.
1701+
body={"text": text, "cardsV2": []},
1702+
params={"updateMask": "text,cardsV2"},
16831703
)
16841704

16851705
self._logger.debug(

src/chat_sdk/adapters/google_chat/cards.py

Lines changed: 58 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ def _convert_child_to_widgets(
133133
elif child_type == "divider":
134134
return [_convert_divider_to_widget()]
135135
elif child_type == "actions":
136-
return [_convert_actions_to_widget(cast("dict[str, Any]", child), endpoint_url)]
136+
return _convert_actions_to_widgets(cast("dict[str, Any]", child), endpoint_url)
137137
elif child_type == "section":
138138
return _convert_section_to_widgets(cast("dict[str, Any]", child), endpoint_url)
139139
elif child_type == "fields":
@@ -184,20 +184,71 @@ def _convert_divider_to_widget() -> dict[str, Any]:
184184
return {"divider": {}}
185185

186186

187-
def _convert_actions_to_widget(
187+
def _convert_actions_to_widgets(
188188
element: dict[str, Any],
189189
endpoint_url: str | None = None,
190-
) -> dict[str, Any]:
191-
"""Convert an actions element to a widget."""
190+
) -> list[dict[str, Any]]:
191+
"""Convert an actions element to widgets.
192+
193+
Buttons accumulate into a single ``buttonList`` widget; ``select`` and
194+
``radio_select`` children become standalone ``selectionInput`` widgets,
195+
interleaved in source order (any pending buttons are flushed first).
196+
"""
197+
widgets: list[dict[str, Any]] = []
192198
buttons: list[dict[str, Any]] = []
199+
200+
def flush_buttons() -> None:
201+
if not buttons:
202+
return
203+
widgets.append({"buttonList": {"buttons": list(buttons)}})
204+
buttons.clear()
205+
193206
for child in element.get("children", []):
194207
child_type = child.get("type")
208+
if child_type == "button":
209+
buttons.append(_convert_button_to_google_button(child, endpoint_url))
210+
continue
195211
if child_type == "link-button":
196212
buttons.append(_convert_link_button_to_google_button(child))
197-
elif child_type == "button":
198-
buttons.append(_convert_button_to_google_button(child, endpoint_url))
213+
continue
214+
if child_type in ("select", "radio_select"):
215+
flush_buttons()
216+
widgets.append(_convert_selection_input_to_widget(child, endpoint_url))
217+
218+
flush_buttons()
219+
220+
return widgets
221+
199222

200-
return {"buttonList": {"buttons": buttons}}
223+
def _convert_selection_input_to_widget(
224+
element: dict[str, Any],
225+
endpoint_url: str | None = None,
226+
) -> dict[str, Any]:
227+
"""Convert a select/radio_select element to a ``selectionInput`` widget."""
228+
initial_option = element.get("initial_option")
229+
items: list[dict[str, Any]] = []
230+
for option in element.get("options", []):
231+
item: dict[str, Any] = {
232+
"text": convert_emoji(option.get("label", "")),
233+
"value": option.get("value", ""),
234+
}
235+
if option.get("value") == initial_option:
236+
item["selected"] = True
237+
items.append(item)
238+
239+
element_id = element.get("id", "")
240+
return {
241+
"selectionInput": {
242+
"name": element_id,
243+
"label": convert_emoji(element.get("label", "")),
244+
"type": "RADIO_BUTTON" if element.get("type") == "radio_select" else "DROPDOWN",
245+
"items": items,
246+
"onChangeAction": {
247+
"function": endpoint_url or element_id,
248+
"parameters": [{"key": "actionId", "value": element_id}],
249+
},
250+
},
251+
}
201252

202253

203254
def _convert_button_to_google_button(

tests/test_gchat_api.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -291,7 +291,31 @@ async def test_edits_text_message(self):
291291
calls = api.get_calls("PATCH", msg_id)
292292
assert len(calls) == 1
293293
assert calls[0]["body"]["text"] is not None
294-
assert calls[0]["params"]["updateMask"] == "text"
294+
# Editing to text must also clear any stranded card (see regression
295+
# test below): updateMask covers both fields and cardsV2 is emptied.
296+
assert calls[0]["params"]["updateMask"] == "text,cardsV2"
297+
298+
@pytest.mark.asyncio
299+
async def test_edit_to_text_clears_cards_v2(self):
300+
# Regression: GChat renders BOTH the text and the previous cardsV2 if
301+
# the card field is left untouched on an edit. Editing a card message
302+
# down to plain text must send updateMask "text,cardsV2" with an empty
303+
# cardsV2 list so the old card is removed. Matches upstream index.ts
304+
# spaces.messages.update (text branch).
305+
adapter, api, _ = await _init_adapter()
306+
tid = _encode_tid("spaces/ABC123")
307+
msg_id = "spaces/ABC123/messages/msg1"
308+
api.set_response("PATCH", msg_id, {"name": msg_id})
309+
310+
await adapter.edit_message(tid, msg_id, "Now just plain text")
311+
312+
calls = api.get_calls("PATCH", msg_id)
313+
assert len(calls) == 1
314+
body = calls[0]["body"]
315+
assert body["cardsV2"] == []
316+
assert body["text"] == "Now just plain text"
317+
mask_fields = set(calls[0]["params"]["updateMask"].split(","))
318+
assert mask_fields == {"text", "cardsV2"}
295319

296320
@pytest.mark.asyncio
297321
async def test_edit_message_api_error(self):

tests/test_gchat_cards.py

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
LinkButton,
2020
Section,
2121
)
22+
from chat_sdk.modals import RadioSelect, Select, SelectOption
2223
from chat_sdk.shared import card_to_fallback_text
2324

2425
# ---------------------------------------------------------------------------
@@ -365,3 +366,173 @@ def test_card_link_converts_to_html_link(self):
365366
"text": '<a href="https://example.com">Click here</a>',
366367
},
367368
}
369+
370+
371+
# ---------------------------------------------------------------------------
372+
# Select / RadioSelect -> selectionInput widgets
373+
# Port of cards.test.ts "converts select actions to selectionInput ..." tests.
374+
# ---------------------------------------------------------------------------
375+
376+
377+
class TestSelectionInputWidgets:
378+
def test_converts_select_actions_to_dropdown_selection_input(self):
379+
card = Card(
380+
children=[
381+
Actions(
382+
[
383+
Select(
384+
id="priority",
385+
label="Priority",
386+
options=[
387+
SelectOption(label="High", value="high", description="Urgent"),
388+
SelectOption(label="Normal", value="normal"),
389+
],
390+
initial_option="normal",
391+
),
392+
]
393+
),
394+
]
395+
)
396+
397+
gchat_card = card_to_google_card(
398+
card,
399+
{"endpoint_url": "https://example.com/api/webhooks/gchat"},
400+
)
401+
402+
widgets = gchat_card["card"]["sections"][0]["widgets"]
403+
assert len(widgets) == 1
404+
assert widgets[0] == {
405+
"selectionInput": {
406+
"name": "priority",
407+
"label": "Priority",
408+
"type": "DROPDOWN",
409+
"items": [
410+
{"text": "High", "value": "high"},
411+
{"text": "Normal", "value": "normal", "selected": True},
412+
],
413+
"onChangeAction": {
414+
"function": "https://example.com/api/webhooks/gchat",
415+
"parameters": [{"key": "actionId", "value": "priority"}],
416+
},
417+
},
418+
}
419+
420+
def test_converts_radio_select_actions_to_radio_selection_input(self):
421+
card = Card(
422+
children=[
423+
Actions(
424+
[
425+
RadioSelect(
426+
id="status",
427+
label="Status",
428+
options=[
429+
SelectOption(label="Open", value="open"),
430+
SelectOption(label="Closed", value="closed"),
431+
],
432+
initial_option="open",
433+
),
434+
]
435+
),
436+
]
437+
)
438+
439+
gchat_card = card_to_google_card(card)
440+
widgets = gchat_card["card"]["sections"][0]["widgets"]
441+
442+
assert len(widgets) == 1
443+
assert widgets[0] == {
444+
"selectionInput": {
445+
"name": "status",
446+
"label": "Status",
447+
"type": "RADIO_BUTTON",
448+
"items": [
449+
{"text": "Open", "value": "open", "selected": True},
450+
{"text": "Closed", "value": "closed"},
451+
],
452+
"onChangeAction": {
453+
# No endpoint URL configured -> function falls back to the id.
454+
"function": "status",
455+
"parameters": [{"key": "actionId", "value": "status"}],
456+
},
457+
},
458+
}
459+
460+
def test_preserves_action_order_for_mixed_buttons_and_selection_inputs(self):
461+
card = Card(
462+
children=[
463+
Actions(
464+
[
465+
Button(id="refresh", label="Refresh"),
466+
Select(
467+
id="category",
468+
label="Category",
469+
options=[
470+
SelectOption(label="Alpha", value="alpha"),
471+
SelectOption(label="Beta", value="beta"),
472+
],
473+
),
474+
LinkButton(url="https://example.com/docs", label="Docs"),
475+
RadioSelect(
476+
id="view",
477+
label="View",
478+
options=[
479+
SelectOption(label="Summary", value="summary"),
480+
SelectOption(label="Detailed", value="detailed"),
481+
],
482+
),
483+
]
484+
),
485+
]
486+
)
487+
488+
gchat_card = card_to_google_card(card)
489+
widgets = gchat_card["card"]["sections"][0]["widgets"]
490+
491+
assert len(widgets) == 4
492+
assert len(widgets[0]["buttonList"]["buttons"]) == 1
493+
assert widgets[0]["buttonList"]["buttons"][0] == {
494+
"text": "Refresh",
495+
"onClick": {
496+
"action": {
497+
"function": "refresh",
498+
"parameters": [{"key": "actionId", "value": "refresh"}],
499+
},
500+
},
501+
}
502+
assert widgets[1]["selectionInput"]["name"] == "category"
503+
assert widgets[1]["selectionInput"]["type"] == "DROPDOWN"
504+
assert widgets[2]["buttonList"]["buttons"] == [
505+
{
506+
"text": "Docs",
507+
"onClick": {
508+
"openLink": {"url": "https://example.com/docs"},
509+
},
510+
},
511+
]
512+
assert widgets[3]["selectionInput"]["name"] == "view"
513+
assert widgets[3]["selectionInput"]["type"] == "RADIO_BUTTON"
514+
515+
def test_selection_input_without_initial_option_marks_none_selected(self):
516+
card = Card(
517+
children=[
518+
Actions(
519+
[
520+
Select(
521+
id="color",
522+
label="Color",
523+
options=[
524+
SelectOption(label="Red", value="red"),
525+
SelectOption(label="Blue", value="blue"),
526+
],
527+
),
528+
]
529+
),
530+
]
531+
)
532+
533+
widgets = card_to_google_card(card)["card"]["sections"][0]["widgets"]
534+
items = widgets[0]["selectionInput"]["items"]
535+
assert items == [
536+
{"text": "Red", "value": "red"},
537+
{"text": "Blue", "value": "blue"},
538+
]

0 commit comments

Comments
 (0)