Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion lib/ash_ui/resources/validations/authoring.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
65 changes: 65 additions & 0 deletions test/ash_ui/resource/locality_test.exs
Original file line number Diff line number Diff line change
@@ -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 ->
Expand Down Expand Up @@ -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
Expand Down
Loading