From 70a33f5e4efd688b3d7529e32b655349cb36333f Mon Sep 17 00:00:00 2001 From: Matt de Courcelle Date: Sat, 13 Jun 2026 06:13:48 -0500 Subject: [PATCH 1/2] feat(rendering): declared phx-click + arbitrary data-* passthrough on LiveUIAdapter Add an additive, opt-in passthrough so an IUR node can declare interaction and identity attributes via its props and have LiveUIAdapter emit them: * "on_click" => %{"event" => "select_doc", "values" => %{"doc_id" => "d1"}} (or a bare "select_doc" string) -> phx-click + phx-value- pairs * "data" => %{"block_id" => "b1"} -> data- attributes WHY: ariston-ui operator Screens need swimlane artifact rows to carry a custom phx-click event + phx-value-* (e.g. select_doc / doc_id) and doc-block widgets to carry data-block-id. Today those are regex-injected into rendered HTML as a post-processing hack (the form-widget clauses only emit a fixed event_name/phx-value-binding_id shape, and the generic fallback emits only class/style/data-widget-id). This gives the renderer a first-class capability and lets ariston-ui drop the regex workaround for #829. A new shared helper passthrough_attrs/1 turns those props into attribute strings via the existing attr/2 helper, wired into the artifact_row clause and the generic/custom widget fallback. Values are HTML-escaped via html_attr/1 and attribute-name keys are restricted to [A-Za-z0-9_-] so a hostile key cannot inject extra attributes. Pairs emit in sorted key order for deterministic output. Backward-compatible + additive only: a node WITHOUT the new props renders byte-identically (passthrough_attrs/1 returns ""). The full existing ash_ui suite stays green (1085 tests, 0 failures). New tests cover on_click -> phx-click+phx-value, data -> data-*, the unchanged-node baseline, value escaping, and hostile-key rejection. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/ash_ui/rendering/live_ui_adapter.ex | 70 +++++++- .../ash_ui/rendering/live_ui_adapter_test.exs | 168 ++++++++++++++++++ 2 files changed, 236 insertions(+), 2 deletions(-) diff --git a/lib/ash_ui/rendering/live_ui_adapter.ex b/lib/ash_ui/rendering/live_ui_adapter.ex index 3aadf35d..67c7570a 100644 --- a/lib/ash_ui/rendering/live_ui_adapter.ex +++ b/lib/ash_ui/rendering/live_ui_adapter.ex @@ -891,7 +891,7 @@ defmodule AshUI.Rendering.LiveUIAdapter do meta = escaped_text_prop(props, "meta") """ -
+

#{title}

#{if meta, do: "

#{meta}

", else: ""} @@ -3086,8 +3086,10 @@ defmodule AshUI.Rendering.LiveUIAdapter do end defp generate_heex(iur, opts) do + props = iur["props"] || %{} + """ -
+
#{generate_children(iur["children"], opts)}
""" @@ -3957,6 +3959,70 @@ defmodule AshUI.Rendering.LiveUIAdapter do defp attr(_name, ""), do: "" defp attr(name, value), do: " #{name}=\"#{value}\"" + # Additive, opt-in passthrough of declared interaction + identity attributes. + # + # A node may declare, via its props: + # + # * `"on_click"` => `%{"event" => "select_doc", "values" => %{"doc_id" => "d1"}}` + # (or a bare event string `"select_doc"`) -> emits + # `phx-click="select_doc"` plus a `phx-value-=""` pair per value. + # * `"data"` => `%{"block_id" => "b1"}` -> emits `data-=""` per pair. + # + # When neither prop is present this returns "" so a node WITHOUT the props + # renders byte-identically to before. Values are HTML-escaped via `html_attr/1`; + # keys are restricted to safe attribute-name characters so a hostile key cannot + # inject additional attributes. Pairs are emitted in sorted key order so output + # is deterministic. + defp passthrough_attrs(props) when is_map(props) do + on_click_attrs(prop(props, "on_click")) <> data_attrs(prop(props, "data")) + end + + defp passthrough_attrs(_props), do: "" + + defp on_click_attrs(nil), do: "" + + defp on_click_attrs(event) when is_binary(event) do + on_click_attrs(%{"event" => event}) + end + + defp on_click_attrs(on_click) when is_map(on_click) do + case prop(on_click, "event") do + event when is_binary(event) and event != "" -> + attr("phx-click", html_attr(event)) <> + value_attrs("phx-value-", prop(on_click, "values")) + + _ -> + "" + end + end + + defp on_click_attrs(_), do: "" + + defp data_attrs(data) when is_map(data) and map_size(data) > 0 do + value_attrs("data-", data) + end + + defp data_attrs(_), do: "" + + defp value_attrs(prefix, values) when is_map(values) do + values + |> Enum.map(fn {key, value} -> {to_string(key), value} end) + |> Enum.filter(fn {key, _value} -> safe_attr_key?(key) end) + |> Enum.sort_by(fn {key, _value} -> key end) + |> Enum.map_join(fn {key, value} -> attr(prefix <> key, html_attr(value)) end) + end + + defp value_attrs(_prefix, _values), do: "" + + # Attribute-name segments may contain letters, digits, hyphen, and underscore. + # Anything else (whitespace, quotes, `=`, `>`...) is rejected so a key can never + # break out of the attribute it names. + defp safe_attr_key?(key) when is_binary(key) do + key != "" and Regex.match?(~r/\A[A-Za-z0-9_-]+\z/, key) + end + + defp safe_attr_key?(_key), do: false + defp merge_style(defaults, extra) do defaults |> List.wrap() diff --git a/test/ash_ui/rendering/live_ui_adapter_test.exs b/test/ash_ui/rendering/live_ui_adapter_test.exs index 4cfbc38b..d2887896 100644 --- a/test/ash_ui/rendering/live_ui_adapter_test.exs +++ b/test/ash_ui/rendering/live_ui_adapter_test.exs @@ -1605,4 +1605,172 @@ defmodule AshUI.Rendering.LiveUIAdapterTest do assert heex =~ "data-live-ui-intent=\"expanded_recent\"" end end + + describe "declared phx-click + data-* attribute passthrough" do + test "artifact_row with on_click emits phx-click + phx-value-* pairs" do + iur = %{ + "type" => "artifact_row", + "id" => "artifact-onclick", + "props" => %{ + "title" => "ADR 0001", + "on_click" => %{ + "event" => "select_doc", + "values" => %{"doc_id" => "doc-42", "lane" => "specs"} + } + }, + "children" => [], + "metadata" => %{} + } + + {:ok, heex} = LiveUIAdapter.render(iur, force_fallback: true) + + assert heex =~ ~s(phx-click="select_doc") + assert heex =~ ~s(phx-value-doc_id="doc-42") + assert heex =~ ~s(phx-value-lane="specs") + assert heex =~ "ash-artifact-row" + assert heex =~ "ADR 0001" + end + + test "artifact_row accepts a bare on_click event string (no values)" do + iur = %{ + "type" => "artifact_row", + "id" => "artifact-onclick-bare", + "props" => %{ + "title" => "ADR 0002", + "on_click" => "select_doc" + }, + "children" => [], + "metadata" => %{} + } + + {:ok, heex} = LiveUIAdapter.render(iur, force_fallback: true) + + assert heex =~ ~s(phx-click="select_doc") + refute heex =~ "phx-value-" + end + + test "artifact_row with data props emits data-* attributes" do + iur = %{ + "type" => "artifact_row", + "id" => "artifact-data", + "props" => %{ + "title" => "Block", + "data" => %{"block_id" => "b-7", "conversation_id" => "c-9"} + }, + "children" => [], + "metadata" => %{} + } + + {:ok, heex} = LiveUIAdapter.render(iur, force_fallback: true) + + assert heex =~ ~s(data-block_id="b-7") + assert heex =~ ~s(data-conversation_id="c-9") + end + + test "generic widget fallback emits on_click + data passthrough attributes" do + iur = %{ + "type" => "custom:operator_tile", + "id" => "tile-1", + "props" => %{ + "on_click" => %{ + "event" => "open_tile", + "values" => %{"tile_id" => "t-1"} + }, + "data" => %{"block_id" => "blk-1"} + }, + "children" => [], + "metadata" => %{} + } + + {:ok, heex} = LiveUIAdapter.render(iur, force_fallback: true) + + assert heex =~ "ash-widget-custom:operator_tile" + assert heex =~ ~s(data-widget-id="tile-1") + assert heex =~ ~s(phx-click="open_tile") + assert heex =~ ~s(phx-value-tile_id="t-1") + assert heex =~ ~s(data-block_id="blk-1") + end + + test "node WITHOUT the passthrough props renders byte-identically" do + base = fn props -> + %{ + "type" => "artifact_row", + "id" => "artifact-baseline", + "props" => props, + "children" => [], + "metadata" => %{} + } + end + + {:ok, without} = LiveUIAdapter.render(base.(%{"title" => "Same"}), force_fallback: true) + + {:ok, also_without} = + LiveUIAdapter.render(base.(%{"title" => "Same"}), force_fallback: true) + + # Baseline is stable, and carries none of the new attributes. + assert without == also_without + refute without =~ "phx-click" + refute without =~ "phx-value-" + refute without =~ "data-block_id" + + {:ok, generic} = + LiveUIAdapter.render( + %{ + "type" => "custom:operator_tile", + "id" => "tile-baseline", + "props" => %{}, + "children" => [], + "metadata" => %{} + }, + force_fallback: true + ) + + refute generic =~ "phx-click" + refute generic =~ "phx-value-" + # Only the pre-existing data-widget-id attribute is present. + assert generic =~ ~s(data-widget-id="tile-baseline") + end + + test "passthrough values are HTML-escaped" do + iur = %{ + "type" => "artifact_row", + "id" => "artifact-escape", + "props" => %{ + "title" => "Escape", + "on_click" => %{ + "event" => "select_doc", + "values" => %{"doc_id" => ~s(a">