diff --git a/lib/ash_ui/resources/validations/authoring.ex b/lib/ash_ui/resources/validations/authoring.ex index 88dbf341..7c78986b 100644 --- a/lib/ash_ui/resources/validations/authoring.ex +++ b/lib/ash_ui/resources/validations/authoring.ex @@ -370,7 +370,18 @@ defmodule AshUI.Resources.Validations.Authoring do "binding #{inspect(Map.get(binding, :id))} targets #{inspect(target)}, which is reserved for screen-scoped bindings" end - if binding_type == :list and not MapSet.member?(@list_widgets, widget_type) do + # `custom:*` widget types are application-extension primitives outside the + # canonical catalog; the validator can't know their collection semantics + # and shouldn't gate the :list binding allow-list on them. Trust the + # authoring intent for these explicitly-tagged opaque types. nil + # widget_type (`ui_element` block missing entirely) does NOT get the + # bypass — that should still surface as a downstream missing-element + # error rather than be silently accepted here. + custom_widget? = + is_binary(widget_type) and String.starts_with?(widget_type, "custom:") + + if binding_type == :list and not custom_widget? and + not MapSet.member?(@list_widgets, widget_type) do raise ArgumentError, "binding #{inspect(Map.get(binding, :id))} declares a list binding on #{inspect(widget_type)}, which does not expose collection semantics" end diff --git a/test/ash_ui/resource/locality_test.exs b/test/ash_ui/resource/locality_test.exs index 1ed1f728..601f46e5 100644 --- a/test/ash_ui/resource/locality_test.exs +++ b/test/ash_ui/resource/locality_test.exs @@ -1,6 +1,27 @@ defmodule AshUI.Resource.LocalityTest do use ExUnit.Case, async: true + # Asserts the binding-locality validator did NOT raise the + # "does not expose collection semantics" ArgumentError for the resource + # compiled inside `fun`. Any other exception (e.g. the unrelated + # domain-registration RuntimeError from the Ash compile-time pipeline) + # is treated as proof the validator passed; absence of any exception is + # also a pass. + defp assert_validator_accepts_list_binding(fun) do + fun.() + :ok + rescue + e in ArgumentError -> + if e.message =~ "does not expose collection semantics" do + flunk("validator rejected list binding it should accept: #{e.message}") + else + :ok + end + + _other -> + :ok + end + describe "screen-scoped binding exceptions" do test "rejects screen bindings that target element-owned props" do assert_raise ArgumentError, ~r/screen-scoped binding .* must target/, fn -> @@ -119,6 +140,50 @@ defmodule AshUI.Resource.LocalityTest do """) end end + + test "accepts list bindings on custom:* opaque widget types" do + # `custom:*` application-extension primitives are outside the canonical + # catalog; the validator can't know their collection semantics and + # should trust the authoring intent. + # + # Same testing strategy as the :artifact_row case above. + assert_validator_accepts_list_binding(fn -> + Code.compile_string(""" + defmodule ValidCustomWidgetListBinding do + use Ash.Resource, + domain: AshUI.Test.ResourceAuthorityDomain, + data_layer: Ash.DataLayer.Ets + + use AshUI.Resource.DSL.Element + + ets do + private?(true) + end + + attributes do + uuid_primary_key(:id) + end + + actions do + defaults([:read]) + end + + ui_element do + type "custom:thread_card" + props %{label: "Custom row template"} + end + + ui_bindings do + binding :thread_rows do + source %{resource: "Demo.Post", relationship: "comments", id: "post-1"} + target "items" + binding_type :list + end + end + end + """) + end) + end end describe "action signal ownership" do