Skip to content

Commit b4c8bcc

Browse files
committed
fix(mcp): repair DataTable onRowClick per-row binding + Slot/RESULT envelope
#494 asked whether the DataTable ``onRowClick`` per-row ``{{ field }}`` substitution actually works end-to-end through the iframe. It does NOT, and the new browser tests prove it: - ``{{ sku }}`` and ``{{ id }}`` are NOT resolved — the renderer's action-scope spreads the clicked row dict at ``$event``, not into the scope's top level. Only ``$event``, ``$result``, ``$error``, and state keys are direct lookups. The renderer leaves unresolved templates as literal strings, so the host calls ``get_variant_details`` with ``sku="{{ sku }}"`` verbatim and the tool either rejects the call or treats it as a missing-SKU lookup. - Even with the substitution fixed, the drill-down still doesn't render because ``SetState("detail", RESULT)`` puts the full PrefabApp envelope (``{$prefab, view, defs, state}``) into ``state.detail``, but ``Slot`` checks ``"type" in D`` on the stored value to decide whether to render — and the envelope's keys don't include ``type``. Slot falls back to its placeholder children every time. Both failure modes are wired together: even if you fix the substitution, the response card silently doesn't render. Both fixes are needed, applied in the same commit so the drill-down ships working end-to-end. Fix shape ========= 1. ``{{ sku }}`` → ``str(EVENT.sku)`` (compiles to ``{{ $event.sku }}``). Same for ``{{ id }}`` → ``str(EVENT.id)``. The ``EVENT`` Rx exposes ``$event`` directly, and ``.sku`` / ``.id`` chains via dot-path to the row dict's fields. Applied to both row-click DataTables: ``build_search_results_ui`` and ``build_item_detail_ui``'s nested variants table. 2. ``on_success=SetState("detail", RESULT)`` → ``on_success=SetState("detail", RESULT.view)``. The ``RESULT`` Rx compiles to ``{{ $result }}`` which evaluates to the full envelope; ``RESULT.view`` evaluates to the root view component, which DOES have ``type`` and renders cleanly inside the Slot. Verification ============ Three new browser tests in ``test_other_cards_render.py``: - ``test_search_results_row_click_passes_clicked_sku`` — clicks row ``SKU-0003``, asserts the stub received ``received_sku="SKU-0003"`` (not the literal template, not None, not some other row's SKU). Cross-process verification via a temp-file the stub writes to. - ``test_item_detail_variant_row_click_passes_clicked_variant_id`` — same shape for the item-detail variants table, asserts ``received_variant_id=700002`` (the middle row's id). - ``test_search_results_row_click_populates_detail_slot`` — full end-to-end DOM assertion: after click, the echoed response card ("Echoed Variant Details" + the SKU) actually renders inside the detail Slot. The substitution-side test ALONE is the diagnostic that catches a host-side regression in #491-class binding handling. Pre-fix it caught the literal-template bug; post-fix it pins the working behavior. The Slot-render test pins the envelope-extraction fix. Empirical note: the renderer attaches the row-click handler at the ``<td>`` cell level, not the ``<tr>`` row level — ``tr.click()`` and ``tr.dispatchEvent("click")`` both fail to fire the handler, while ``td.click()`` does. The tests use the cell selector as the canonical click target. Closes #494.
1 parent 7688267 commit b4c8bcc

5 files changed

Lines changed: 277 additions & 24 deletions

File tree

katana_mcp_server/src/katana_mcp/tools/prefab_ui.py

Lines changed: 27 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@
7575
)
7676
from prefab_ui.components.control_flow import Elif, Else, If
7777
from prefab_ui.components.slot import Slot
78-
from prefab_ui.rx import RESULT, Rx
78+
from prefab_ui.rx import EVENT, RESULT, Rx
7979
from pydantic import BaseModel
8080

8181
from katana_mcp.tools.tool_result_utils import BLOCK_WARNING_PREFIX
@@ -526,18 +526,25 @@ def build_search_results_ui(
526526
search=True,
527527
paginated=True,
528528
pageSize=20,
529-
# NOTE: ``{{ sku }}`` and ``{{ $error }}`` here are *per-row /
530-
# event-context* bindings provided by the DataTable component
531-
# itself, NOT the iframe-state substitution that broke in #491.
532-
# The DataTable renderer expands these client-side from the
533-
# clicked row's data and the action's error payload, so they
534-
# do not depend on the host-side Mustache-from-state mechanism
535-
# that silently dropped args. Reliability is owned by the
536-
# DataTable component; verification is tracked in #494.
529+
# Per-row binding via ``$event``: the renderer spreads the
530+
# clicked row's dict into the action-scope as ``$event``, so
531+
# ``{{ $event.sku }}`` resolves to the row's SKU. The naive
532+
# form ``{{ sku }}`` does NOT work — only top-level scope keys
533+
# ($event, $result, $error, state keys) are direct lookups;
534+
# the row dict itself is not spread (#494, verified by browser
535+
# test ``test_search_results_row_click_passes_clicked_sku``).
536+
# ``{{ $error }}`` works because $error IS a top-level key.
537+
#
538+
# ``RESULT.view`` (not bare ``RESULT``) on the success path:
539+
# the tool returns a full PrefabApp envelope
540+
# (``{$prefab, view, defs, state}``) and the Slot renderer
541+
# expects a single component dict with a top-level ``type``.
542+
# Setting ``state.detail = $result.view`` extracts the
543+
# root view component so the Slot can render it (#494).
537544
onRowClick=CallTool(
538545
"get_variant_details",
539-
arguments={"sku": "{{ sku }}"},
540-
on_success=SetState("detail", RESULT),
546+
arguments={"sku": str(EVENT.sku)},
547+
on_success=SetState("detail", RESULT.view),
541548
on_error=ShowToast("{{ $error }}", variant="error"),
542549
),
543550
)
@@ -963,18 +970,18 @@ def _item_variants_table(item: dict[str, Any]) -> None:
963970
# Per-row click invokes get_variant_details using the row's
964971
# variant id, not its SKU. ``ItemVariantSummary.sku`` is
965972
# nullable (Katana allows variants without a SKU on the wire),
966-
# so a click on a SKU-less row would otherwise produce
967-
# ``arguments={"sku": null}`` and the tool would reject the
968-
# call with "must provide at least one of: sku, variant_id,
969-
# skus, variant_ids". ``id`` is always present on the
973+
# so binding by SKU would reject every SKU-less row with the
974+
# tool's "must provide at least one of: sku, variant_id, skus,
975+
# variant_ids" error. ``id`` is always present on the
970976
# ``ItemVariantSummary`` shape, so this path stays clickable
971-
# for every row. DataTable's per-row ``{{ id }}`` binding
972-
# expands client-side from row data (not iframe state) so it
973-
# doesn't hit the #491 state-binding failure mode.
977+
# for every row. Per-row substitution uses ``EVENT.id`` (which
978+
# compiles to ``{{ $event.id }}``) because the row dict isn't
979+
# spread into scope — see the comment on the search_results
980+
# DataTable for the full reasoning (#494).
974981
onRowClick=CallTool(
975982
"get_variant_details",
976-
arguments={"variant_id": "{{ id }}"},
977-
on_success=SetState("detail", RESULT),
983+
arguments={"variant_id": str(EVENT.id)},
984+
on_success=SetState("detail", RESULT.view),
978985
on_error=ShowToast("{{ $error }}", variant="error"),
979986
),
980987
)

katana_mcp_server/tests/browser/render_test_server.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@
1212

1313
from __future__ import annotations
1414

15+
import json
16+
import tempfile
1517
from collections.abc import Callable
18+
from pathlib import Path
1619
from typing import Any
1720

1821
from fastmcp import FastMCP
@@ -25,6 +28,7 @@
2528
from katana_mcp.tools.prefab_ui import (
2629
build_batch_recipe_update_ui,
2730
build_inventory_check_ui,
31+
build_item_detail_ui,
2832
build_modification_preview_ui,
2933
build_modification_result_ui,
3034
build_search_results_ui,
@@ -284,6 +288,42 @@ def _verification_app() -> PrefabApp:
284288
return build_verification_ui(response)
285289

286290

291+
def _item_detail_app() -> PrefabApp:
292+
"""build_item_detail_ui with 3 variants — exercises the variants
293+
DataTable's per-row ``onRowClick=CallTool(get_variant_details,
294+
arguments={"variant_id": "{{ id }}"})`` binding (#494).
295+
"""
296+
item = {
297+
"id": 9001,
298+
"name": "Test Product",
299+
"type": "product",
300+
"uom": "pcs",
301+
"is_sellable": True,
302+
"is_producible": True,
303+
"variants": [
304+
{
305+
"id": 700001,
306+
"sku": "VAR-A",
307+
"sales_price": 10.00,
308+
"purchase_price": 4.00,
309+
},
310+
{
311+
"id": 700002,
312+
"sku": "VAR-B",
313+
"sales_price": 20.00,
314+
"purchase_price": 8.00,
315+
},
316+
{
317+
"id": 700003,
318+
"sku": "VAR-C",
319+
"sales_price": 30.00,
320+
"purchase_price": 12.00,
321+
},
322+
],
323+
}
324+
return build_item_detail_ui(item)
325+
326+
287327
def _batch_recipe_update_app() -> PrefabApp:
288328
"""build_batch_recipe_update_ui with 5 sub-ops — exercises rows='{{ rows }}'."""
289329
response = {
@@ -405,6 +445,7 @@ def _stock_adjustment_delete_response(*, is_preview: bool) -> dict:
405445
# Audit coverage: post-mustache-fix render checks for every other
406446
# state-bound DataTable card.
407447
"search_results": _search_results_app,
448+
"item_detail": _item_detail_app,
408449
"inventory_check": _inventory_check_app,
409450
"verification": _verification_app,
410451
"batch_recipe_update": _batch_recipe_update_app,
@@ -537,5 +578,60 @@ async def modify_manufacturing_order(
537578
return make_tool_result(response, ui=ui)
538579

539580

581+
class _EchoVariantDetails(BaseModel):
582+
"""Echoes back the arguments the host actually passed to
583+
``get_variant_details`` — used by the #494 row-click tests to verify
584+
DataTable per-row ``{{ field }}`` substitution resolved against the
585+
clicked row's data (not null, not the literal Mustache string).
586+
"""
587+
588+
received_sku: str | None = None
589+
received_variant_id: int | None = None
590+
591+
592+
# Fixed cross-process path so the browser-test subprocess and the
593+
# pytest process agree on where the stub writes its received args.
594+
# Cleared before each row-click test and read back after the click.
595+
GET_VARIANT_DETAILS_RECORD_PATH = (
596+
Path(tempfile.gettempdir()) / "katana_test_get_variant_details_received.json"
597+
)
598+
599+
600+
@mcp.tool(meta={"ui": True})
601+
async def get_variant_details(
602+
sku: str | None = None,
603+
variant_id: int | None = None,
604+
) -> ToolResult:
605+
"""Echo stub for #494 row-click binding tests.
606+
607+
The two row-click drill-downs that exercise per-row binding:
608+
609+
- ``build_search_results_ui``: ``arguments={"sku": "{{ sku }}"}``
610+
- ``build_item_detail_ui`` variants table: ``arguments={"variant_id":
611+
"{{ id }}"}``
612+
613+
A correctly-substituting host calls this with the clicked row's
614+
actual value. A broken host calls it with ``None`` (silent drop), the
615+
literal string ``"{{ sku }}"``, or fires ``on_error``. The stub
616+
records whichever value the host actually delivered to a cross-process
617+
file at :data:`GET_VARIANT_DETAILS_RECORD_PATH` so the browser test
618+
can read it back after the click — the response card itself can't
619+
be used as the assertion target because the Slot/RESULT envelope
620+
mismatch (separately filed) prevents the response from rendering in
621+
the ``detail`` slot.
622+
"""
623+
received = {"received_sku": sku, "received_variant_id": variant_id}
624+
GET_VARIANT_DETAILS_RECORD_PATH.write_text(json.dumps(received))
625+
response = _EchoVariantDetails(received_sku=sku, received_variant_id=variant_id)
626+
from prefab_ui.components import Card, CardContent, CardTitle, Text
627+
628+
with PrefabApp(state={}, css_class="p-4") as ui, Card():
629+
CardTitle(content="Echoed Variant Details")
630+
with CardContent():
631+
Text(content=f"received_sku={sku!r}")
632+
Text(content=f"received_variant_id={variant_id!r}")
633+
return make_tool_result(response, ui=ui)
634+
635+
540636
if __name__ == "__main__":
541637
mcp.run(transport="http", port=8765)

katana_mcp_server/tests/browser/test_other_cards_render.py

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,23 @@
1515

1616
from __future__ import annotations
1717

18+
import json
19+
import tempfile
20+
import time
21+
from pathlib import Path
22+
1823
import pytest
1924

2025
pytestmark = pytest.mark.browser
2126

27+
# Pinned to match ``render_test_server.GET_VARIANT_DETAILS_RECORD_PATH``
28+
# so the test process and the dev-server subprocess agree on where the
29+
# stub writes its received args. If the constant moves, both sides need
30+
# to update.
31+
_GET_VARIANT_DETAILS_RECORD_PATH = (
32+
Path(tempfile.gettempdir()) / "katana_test_get_variant_details_received.json"
33+
)
34+
2235

2336
class TestOtherCardsRender:
2437
"""Verify state-bound DataTables outside the modification surface
@@ -84,3 +97,137 @@ def test_batch_recipe_update_renders(self, render_scenario):
8497
assert frame.locator("table tr").count() >= 6
8598
# SKU strings from the test fixture.
8699
assert frame.locator("text=SKU-OLD-0").count() >= 1
100+
101+
102+
class TestDataTableRowClickBinding:
103+
"""Verify DataTable ``onRowClick`` ``{{ field }}`` per-row bindings
104+
actually resolve client-side against the clicked row's data (#494).
105+
106+
Background: #491 found that the MCP host silently dropped Mustache
107+
``{{ request.<field> }}`` arguments in CallTool actions (host-state
108+
substitution). The fix in #493 inlined values at preview-build time
109+
for the order/receipt/batch/fulfill builders, but deliberately left
110+
the DataTable per-row ``{{ sku }}`` and ``{{ id }}`` bindings alone
111+
because those expand via the DataTable component's own row-context
112+
machinery (``$event`` is the row dict per the component docstring)
113+
— a different code path. These tests prove that path actually works
114+
end-to-end through the iframe.
115+
116+
Both card builders' row-click handlers route to a stub
117+
``get_variant_details`` (defined in ``render_test_server.py``) that
118+
echoes back whichever argument the host actually delivered. The
119+
on_success ``SetState("detail", RESULT)`` then renders the echoed
120+
card in a ``Slot(name="detail")``. The test asserts the echoed text
121+
matches the clicked row's data — proving substitution worked.
122+
123+
Failure modes the tests catch:
124+
- Host drops the binding silently → echo shows ``None`` → fail
125+
- Host emits the literal Mustache string → echo shows ``"{{ sku }}"``
126+
→ fail
127+
- on_error fires (host rejects the call upfront) → toast appears
128+
but the detail slot never populates → fail (wait_for times out)
129+
"""
130+
131+
def _wait_and_read_record(self, timeout_s: float = 15.0) -> dict:
132+
"""Poll the cross-process record file until the stub writes to it,
133+
then parse + return its contents."""
134+
deadline = time.monotonic() + timeout_s
135+
while time.monotonic() < deadline:
136+
if _GET_VARIANT_DETAILS_RECORD_PATH.exists():
137+
return json.loads(_GET_VARIANT_DETAILS_RECORD_PATH.read_text())
138+
time.sleep(0.25)
139+
raise AssertionError(
140+
f"Stub never wrote to {_GET_VARIANT_DETAILS_RECORD_PATH} within "
141+
f"{timeout_s}s — the row-click never reached the server. Either "
142+
"the host dropped the CallTool, or the click target didn't fire "
143+
"onRowClick."
144+
)
145+
146+
@pytest.fixture(autouse=True)
147+
def _clear_record(self):
148+
"""Wipe the cross-process record file before each test so a stale
149+
write from a prior test can't masquerade as a fresh substitution."""
150+
_GET_VARIANT_DETAILS_RECORD_PATH.unlink(missing_ok=True)
151+
yield
152+
_GET_VARIANT_DETAILS_RECORD_PATH.unlink(missing_ok=True)
153+
154+
def test_search_results_row_click_passes_clicked_sku(self, render_scenario):
155+
"""``build_search_results_ui`` DataTable: clicking row N fires
156+
``get_variant_details(sku="SKU-NNNN")`` with the row's own SKU.
157+
158+
Picks "SKU-0003" — far enough from the first row that a
159+
first-row-only binding would also be caught. Clicks a ``<td>``
160+
cell within the row because the Prefab DataTable renderer
161+
currently attaches the click listener at the cell level (verified
162+
empirically — ``tr.click()`` and ``tr.dispatchEvent("click")``
163+
do not fire the handler, but a click on a contained ``<td>``
164+
does).
165+
"""
166+
frame = render_scenario("search_results")
167+
# Click the SKU cell within the row whose SKU is "SKU-0003".
168+
frame.locator("td.pf-table-cell").filter(has_text="SKU-0003").first.click()
169+
170+
record = self._wait_and_read_record()
171+
# Substitution must have resolved the {{ sku }} binding against
172+
# the clicked row's data — not None, not the literal Mustache
173+
# string, not some other row's SKU.
174+
assert record["received_sku"] == "SKU-0003", (
175+
f"Expected received_sku='SKU-0003' (the clicked row's SKU). "
176+
f"Got: {record!r}. If 'received_sku' is None the host silently "
177+
f"dropped the binding. If it's '{{ sku }}' the host emitted the "
178+
f"literal template. Either is the #491-class failure mode."
179+
)
180+
# search_results binds only {{ sku }}, not {{ id }} — variant_id
181+
# should be absent.
182+
assert record["received_variant_id"] is None
183+
184+
def test_item_detail_variant_row_click_passes_clicked_variant_id(
185+
self, render_scenario
186+
):
187+
"""``build_item_detail_ui`` variants DataTable: clicking variant
188+
row N fires ``get_variant_details(variant_id=N)`` with the row's
189+
own id. Pins the #494 fix path for the card landed in #698.
190+
191+
Picks variant_id=700002 (the middle row) so first-row-only and
192+
last-row-only bugs would both fail this test. The middle row's
193+
SKU is "VAR-B" — click that cell to drive the row-click.
194+
"""
195+
frame = render_scenario("item_detail")
196+
frame.locator("td.pf-table-cell").filter(has_text="VAR-B").first.click()
197+
198+
record = self._wait_and_read_record()
199+
assert record["received_variant_id"] == 700002, (
200+
f"Expected received_variant_id=700002 (the clicked row's id). "
201+
f"Got: {record!r}. The item_detail variants DataTable binds "
202+
f"{{ id }} via on_row_click — a None or wrong-row value here "
203+
f"means the row-context substitution is broken."
204+
)
205+
# item_detail binds only {{ id }}, not {{ sku }}.
206+
assert record["received_sku"] is None
207+
208+
def test_search_results_row_click_populates_detail_slot(self, render_scenario):
209+
"""End-to-end drill-down: clicking a row not only fires the
210+
CallTool with the right SKU, but the returned card actually
211+
renders inside the ``Slot(name="detail")``.
212+
213+
The Slot-side fix paired with the substitution fix: a bare
214+
``SetState("detail", RESULT)`` would put the PrefabApp envelope
215+
(``{$prefab, view, defs, state}``) into state, which the Slot
216+
can't render because it checks ``"type" in D`` on the stored
217+
value. ``RESULT.view`` (``{{ $result.view }}``) extracts the
218+
root view component, which DOES have ``type``, so the Slot
219+
renders the response card.
220+
"""
221+
frame = render_scenario("search_results")
222+
# Pre-click: fallback content is visible; echoed card is not.
223+
assert frame.locator("text=Click a row to see").count() >= 1
224+
assert frame.locator("text=Echoed Variant Details").count() == 0
225+
226+
frame.locator("td.pf-table-cell").filter(has_text="SKU-0007").first.click()
227+
228+
# The echoed response card appears in the detail slot.
229+
frame.locator("text=Echoed Variant Details").wait_for(
230+
state="visible", timeout=15000
231+
)
232+
# And it shows the clicked row's SKU echoed back.
233+
assert frame.locator("text=received_sku='SKU-0007'").count() >= 1

katana_mcp_server/tests/test_prefab_ui.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -773,9 +773,12 @@ def test_variants_render_as_datatable_with_callTool_drilldown(self):
773773
assert on_row_click.get("tool") == "get_variant_details"
774774
# Argument keys off the row's ``id`` field, not ``sku``. Using
775775
# ``id`` keeps SKU-less variants (legitimate per
776-
# ``ItemVariantSummary.sku: str | None``) clickable.
776+
# ``ItemVariantSummary.sku: str | None``) clickable. The template
777+
# uses ``$event.id`` (not bare ``id``) because the renderer
778+
# spreads the row dict at ``$event``, not into the scope's top
779+
# level (#494, verified via browser test).
777780
args = on_row_click.get("arguments") or {}
778-
assert args.get("variant_id") == "{{ id }}"
781+
assert args.get("variant_id") == "{{ $event.id }}"
779782
assert "sku" not in args
780783

781784
def test_variants_table_handles_sku_less_variants(self):
@@ -805,7 +808,7 @@ def test_variants_table_handles_sku_less_variants(self):
805808
assert len(tables) == 1
806809
# Row-click args reference the variant id, not sku.
807810
args = tables[0].get("onRowClick", {}).get("arguments") or {}
808-
assert args == {"variant_id": "{{ id }}"}
811+
assert args == {"variant_id": "{{ $event.id }}"}
809812
# The variant count metric reflects the single row.
810813
assert "Variants: 1" in str(envelope)
811814

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)