diff --git a/README.md b/README.md index 4eb1f8b0..dce72879 100644 --- a/README.md +++ b/README.md @@ -106,9 +106,9 @@ Key starting points: - [DG-0001: Architecture Overview](/Users/Pascal/code/ash/ash_ui/guides/developer/DG-0001-architecture-overview.md) - [Example: basic dashboard](/Users/Pascal/code/ash/ash_ui/examples/basic_dashboard/README.md) -## Current Phase +## Current Status -The project is in Phase 8, focused on governance gates and release readiness. CI, conformance coverage, observability, and documentation are now first-class parts of the repo instead of placeholders. +Phase 8 governance work is complete, but the repo is still closing feature gaps in earlier phases. The current implementation is strongest in resource storage, compilation, runtime wiring, observability, and governance, while real Ash-backed binding execution and full external renderer integration remain open in reopened Phase 1, 3, 4, 5, and 7 workstreams. ## Development Notes diff --git a/config/config.exs b/config/config.exs index 1218b023..9b4b7012 100644 --- a/config/config.exs +++ b/config/config.exs @@ -26,6 +26,8 @@ config :ash_ui, :rendering, default_renderer: :liveview, # Enable automatic renderer detection based on context auto_detect: true, + # Allow adapter fallback when external renderer packages are not installed + allow_adapter_fallback: true, # Fallback renderer if primary is unavailable fallback_renderer: nil, # Renderer-specific options diff --git a/lib/ash_ui/authorization/binding_policy.ex b/lib/ash_ui/authorization/binding_policy.ex index 1833df4b..a4e493a5 100644 --- a/lib/ash_ui/authorization/binding_policy.ex +++ b/lib/ash_ui/authorization/binding_policy.ex @@ -6,36 +6,69 @@ defmodule AshUI.Authorization.BindingPolicy do """ alias AshUI.Authorization.Policies + alias AshUI.Authorization.ScreenPolicy @doc """ Defines policies for binding resource access. """ def policies do [ - %Ash.Policy.Policy{description: "Bindings are evaluable if parent screen is accessible", policies: []}, - %Ash.Policy.Policy{description: "Can create bindings if can modify parent screen", policies: []}, - %Ash.Policy.Policy{description: "Can update bindings if can modify parent screen", policies: []}, - %Ash.Policy.Policy{description: "Can delete bindings if can modify parent screen", policies: []}, + %Ash.Policy.Policy{ + description: "Bindings are evaluable if parent screen is accessible", + policies: [] + }, + %Ash.Policy.Policy{ + description: "Can create bindings if can modify parent screen", + policies: [] + }, + %Ash.Policy.Policy{ + description: "Can update bindings if can modify parent screen", + policies: [] + }, + %Ash.Policy.Policy{ + description: "Can delete bindings if can modify parent screen", + policies: [] + }, %Ash.Policy.Policy{description: "Must have access to binding source data", policies: []} ] end + @doc """ + Checks whether the actor can read a binding. + """ + def can_read?(user, binding), do: can_evaluate?(user, binding) + + @doc """ + Checks whether the actor can create, update, or delete a binding. + """ + def can_manage?(user, binding) do + cond do + Policies.runtime_authorization_bypass?() -> true + not Policies.user_active(user) -> false + Policies.user_role(user, :admin) -> true + not Policies.role_allowed?(user, binding) -> false + screen_owned?(user, binding) -> true + Policies.screen_owner(user, binding) -> true + Policies.unrestricted_resource?(binding) -> true + true -> false + end + end + @doc """ Check if user can evaluate a binding. """ def can_evaluate?(user, binding) do cond do Policies.runtime_authorization_bypass?() -> true - + not Policies.resource_active?(binding) -> false # Admins can evaluate all bindings Policies.user_role(user, :admin) -> true - # User must be active not Policies.user_active(user) -> false - + not Policies.role_allowed?(user, binding) -> false + not screen_accessible?(user, binding) -> false # Check data source access not has_data_access?(binding, user) -> false - # Default allow true -> true end @@ -47,19 +80,16 @@ defmodule AshUI.Authorization.BindingPolicy do def can_write?(user, binding) do cond do Policies.runtime_authorization_bypass?() -> true - # Admins can write to all bindings Policies.user_role(user, :admin) -> true - # User must be active not Policies.user_active(user) -> false - + not Policies.role_allowed?(user, binding) -> false # Check if binding is read-only - Map.get(binding, :read_only, false) -> false - + read_only?(binding) -> false + not screen_owned?(user, binding) -> false # Check write access to data source not has_write_access?(binding, user) -> false - # Default allow true -> true end @@ -82,16 +112,16 @@ defmodule AshUI.Authorization.BindingPolicy do @doc """ Check if binding source resource is accessible. """ - def source_accessible?(_user, binding) do + def source_accessible?(user, binding) do source = normalize_source(binding) cond do # No source means no restriction map_size(source) == 0 -> true - # Check resource-level access not Policies.can_read_source(binding) -> false - + # Check field-level access + not field_accessible?(user, binding) -> false # Default allow true -> true end @@ -99,30 +129,72 @@ defmodule AshUI.Authorization.BindingPolicy do # Private functions - defp has_data_access?(binding, _user) do + defp screen_accessible?(user, binding) do + case loaded_screen(binding) do + %{} = screen -> ScreenPolicy.can_read?(user, screen) + _ -> true + end + end + + defp screen_owned?(user, binding) do + case loaded_screen(binding) do + %{} = screen -> + ScreenPolicy.can_manage?(user, screen) + + _ -> + Policies.screen_owner(user, binding) || Policies.unrestricted_resource?(binding) + end + end + + defp loaded_screen(resource) do + case Map.get(resource, :screen) || Map.get(resource, "screen") do + %Ash.NotLoaded{} -> nil + screen -> screen + end + end + + defp has_data_access?(binding, user) do source = normalize_source(binding) cond do map_size(source) == 0 -> true - not Policies.can_read_source(binding) -> false + not Policies.can_read_source(binding, user) -> false + not Policies.can_access_field(binding, Map.get(source, "field")) -> false true -> true end end - defp has_write_access?(binding, _user) do + defp has_write_access?(binding, user) do source = normalize_source(binding) cond do map_size(source) == 0 -> true - not Policies.can_write_source(binding) -> false + not Policies.can_write_source(binding, user) -> false true -> true end end + defp field_accessible?(_user, binding) do + source = normalize_source(binding) + field = Map.get(source, "field") + + case field do + nil -> true + _ -> Policies.can_access_field(binding, field) + end + end + defp normalize_source(binding) do case Map.get(binding, :source) do source when is_map(source) -> source + nil -> Map.get(binding, "source") || %{} _ -> %{} end end + + defp read_only?(binding) do + Map.get(binding, :read_only) || + Map.get(binding, "read_only") || + false + end end diff --git a/lib/ash_ui/authorization/checks/binding_access.ex b/lib/ash_ui/authorization/checks/binding_access.ex new file mode 100644 index 00000000..2d407a67 --- /dev/null +++ b/lib/ash_ui/authorization/checks/binding_access.ex @@ -0,0 +1,33 @@ +defmodule AshUI.Authorization.Checks.BindingAccess do + @moduledoc """ + Ash policy check that routes binding authorization through `BindingPolicy`. + """ + + use Ash.Policy.SimpleCheck + + alias AshUI.Authorization.BindingPolicy + alias AshUI.Authorization.Subject + + @impl true + @doc """ + Describes the binding access mode being evaluated. + """ + def describe(opts), do: "binding #{Keyword.get(opts, :mode, :read)} access" + + @impl true + @doc """ + Evaluates binding access for the supplied actor and policy subject. + """ + def match?(actor, %{subject: subject}, opts) do + binding = Subject.to_data(subject) + + allowed = + case Keyword.get(opts, :mode, :read) do + :read -> BindingPolicy.can_read?(actor, binding) + :manage -> BindingPolicy.can_manage?(actor, binding) + _ -> false + end + + {:ok, allowed} + end +end diff --git a/lib/ash_ui/authorization/checks/element_access.ex b/lib/ash_ui/authorization/checks/element_access.ex new file mode 100644 index 00000000..20e531e8 --- /dev/null +++ b/lib/ash_ui/authorization/checks/element_access.ex @@ -0,0 +1,33 @@ +defmodule AshUI.Authorization.Checks.ElementAccess do + @moduledoc """ + Ash policy check that routes element authorization through `ElementPolicy`. + """ + + use Ash.Policy.SimpleCheck + + alias AshUI.Authorization.ElementPolicy + alias AshUI.Authorization.Subject + + @impl true + @doc """ + Describes the element access mode being evaluated. + """ + def describe(opts), do: "element #{Keyword.get(opts, :mode, :read)} access" + + @impl true + @doc """ + Evaluates element access for the supplied actor and policy subject. + """ + def match?(actor, %{subject: subject}, opts) do + element = Subject.to_data(subject) + + allowed = + case Keyword.get(opts, :mode, :read) do + :read -> ElementPolicy.can_read?(actor, element) + :manage -> ElementPolicy.can_manage?(actor, element) + _ -> false + end + + {:ok, allowed} + end +end diff --git a/lib/ash_ui/authorization/checks/screen_access.ex b/lib/ash_ui/authorization/checks/screen_access.ex new file mode 100644 index 00000000..d40904ab --- /dev/null +++ b/lib/ash_ui/authorization/checks/screen_access.ex @@ -0,0 +1,34 @@ +defmodule AshUI.Authorization.Checks.ScreenAccess do + @moduledoc """ + Ash policy check that routes screen authorization through `ScreenPolicy`. + """ + + use Ash.Policy.SimpleCheck + + alias AshUI.Authorization.ScreenPolicy + alias AshUI.Authorization.Subject + + @impl true + @doc """ + Describes the screen access mode being evaluated. + """ + def describe(opts), do: "screen #{Keyword.get(opts, :mode, :read)} access" + + @impl true + @doc """ + Evaluates screen access for the supplied actor and policy subject. + """ + def match?(actor, %{subject: subject}, opts) do + screen = Subject.to_data(subject) + + allowed = + case Keyword.get(opts, :mode, :read) do + :mount -> ScreenPolicy.can_mount?(actor, screen) + :read -> ScreenPolicy.can_read?(actor, screen) + :manage -> ScreenPolicy.can_manage?(actor, screen) + _ -> false + end + + {:ok, allowed} + end +end diff --git a/lib/ash_ui/authorization/element_policy.ex b/lib/ash_ui/authorization/element_policy.ex index e70a9156..2e1f49d4 100644 --- a/lib/ash_ui/authorization/element_policy.ex +++ b/lib/ash_ui/authorization/element_policy.ex @@ -6,36 +6,59 @@ defmodule AshUI.Authorization.ElementPolicy do """ alias AshUI.Authorization.Policies + alias AshUI.Authorization.ScreenPolicy @doc """ Defines policies for element resource access. """ def policies do [ - %Ash.Policy.Policy{description: "Elements are visible if parent screen is accessible", policies: []}, - %Ash.Policy.Policy{description: "Can create elements if can modify parent screen", policies: []}, - %Ash.Policy.Policy{description: "Can update elements if can modify parent screen", policies: []}, - %Ash.Policy.Policy{description: "Can delete elements if can modify parent screen", policies: []}, + %Ash.Policy.Policy{ + description: "Elements are visible if parent screen is accessible", + policies: [] + }, + %Ash.Policy.Policy{ + description: "Can create elements if can modify parent screen", + policies: [] + }, + %Ash.Policy.Policy{ + description: "Can update elements if can modify parent screen", + policies: [] + }, + %Ash.Policy.Policy{ + description: "Can delete elements if can modify parent screen", + policies: [] + }, %Ash.Policy.Policy{description: "Respects element visibility conditions", policies: []} ] end + @doc """ + Checks whether the actor can read an element. + """ + def can_read?(user, element), do: visible?(user, element) + + @doc """ + Checks whether the actor can create, update, or delete an element. + """ + def can_manage?(user, element), do: editable?(user, element) + @doc """ Check if element should be visible to user. """ def visible?(user, element) do cond do Policies.runtime_authorization_bypass?() -> true - + not Policies.resource_active?(element) -> false # Admins see all elements Policies.user_role(user, :admin) -> true - # User must be active not Policies.user_active(user) -> false - + not Policies.role_allowed?(user, element) -> false # Check element visibility conditions not meets_visibility_condition?(element, user) -> false - + # Check parent screen access + not screen_accessible?(user, element) -> false # Default visible true -> true end @@ -47,16 +70,15 @@ defmodule AshUI.Authorization.ElementPolicy do def editable?(user, element) do cond do Policies.runtime_authorization_bypass?() -> true - # Admins can edit all elements Policies.user_role(user, :admin) -> true - # User must be active not Policies.user_active(user) -> false - + not Policies.role_allowed?(user, element) -> false # Check if element is explicitly read-only - Map.get(element, :read_only, false) -> false - + read_only?(element) -> false + # Must own parent screen + not screen_owned?(user, element) -> false # Default editable true -> true end @@ -64,14 +86,48 @@ defmodule AshUI.Authorization.ElementPolicy do # Private functions + defp screen_accessible?(user, element) do + case loaded_screen(element) do + %{} = screen -> ScreenPolicy.can_read?(user, screen) + _ -> true + end + end + + defp screen_owned?(user, element) do + case loaded_screen(element) do + %{} = screen -> + ScreenPolicy.can_manage?(user, screen) + + _ -> + Policies.screen_owner(user, element) || Policies.unrestricted_resource?(element) + end + end + + defp loaded_screen(resource) do + case Map.get(resource, :screen) || Map.get(resource, "screen") do + %Ash.NotLoaded{} -> nil + screen -> screen + end + end + defp meets_visibility_condition?(element, user) do case Map.get(element, :visible_when) do - nil -> true + nil -> + true + {field, value} -> # Check user field matches required value Map.get(user, field) == value - condition when is_function(condition, 1) -> condition.(user) - _ -> true + + condition when is_function(condition, 1) -> + condition.(user) + + _ -> + true end end + + defp read_only?(element) do + Map.get(element, :read_only) || Map.get(element, "read_only") || false + end end diff --git a/lib/ash_ui/authorization/policies.ex b/lib/ash_ui/authorization/policies.ex index 5c74fbca..39ca6456 100644 --- a/lib/ash_ui/authorization/policies.ex +++ b/lib/ash_ui/authorization/policies.ex @@ -6,6 +6,14 @@ defmodule AshUI.Authorization.Policies do to UI screens, elements, and bindings. """ + alias AshUI.Authorization.BindingPolicy + alias AshUI.Authorization.ElementPolicy + alias AshUI.Authorization.ScreenPolicy + alias AshUI.Runtime.ResourceAccess + alias AshUI.Resources.Binding + alias AshUI.Resources.Element + alias AshUI.Resources.Screen + @type policy_result :: :authorized | :forbidden | {:error, term()} @doc """ @@ -68,12 +76,79 @@ defmodule AshUI.Authorization.Policies do def screen_owner(user, resource) do case {user, resource} do {nil, _} -> false - {%{id: user_id}, %{owner_id: owner_id}} when not is_nil(owner_id) -> user_id == owner_id - {%{id: user_id}, %{user_id: user_id}} -> true + {%{id: user_id}, _resource} when not is_nil(user_id) -> user_id == owner_id(resource) _ -> false end end + @doc """ + Returns whether a resource is marked active. + """ + @spec resource_active?(map()) :: boolean() + def resource_active?(resource) do + case resource_value(resource, :active) do + false -> false + _ -> true + end + end + + @doc """ + Returns whether a resource is explicitly public. + """ + @spec public_resource?(map()) :: boolean() + def public_resource?(resource), do: resource_value(resource, :public) == true + + @doc """ + Returns whether a resource is explicitly marked private. + """ + @spec explicitly_private_resource?(map()) :: boolean() + def explicitly_private_resource?(resource), do: resource_value(resource, :public) == false + + @doc """ + Returns the owner ID for a resource from top-level fields or metadata. + """ + @spec owner_id(map()) :: term() + def owner_id(resource) do + resource_value(resource, :owner_id) || resource_value(resource, :user_id) + end + + @doc """ + Returns the required roles for a resource. + """ + @spec required_roles(map()) :: [atom()] + def required_roles(resource) do + resource + |> resource_value(:required_roles, resource_value(resource, :required_role)) + |> case do + nil -> [] + roles when is_list(roles) -> Enum.map(roles, &normalize_role/1) + role -> [normalize_role(role)] + end + |> Enum.reject(&is_nil/1) + |> Enum.uniq() + end + + @doc """ + Returns whether the user satisfies any role requirement on the resource. + """ + @spec role_allowed?(term(), map()) :: boolean() + def role_allowed?(user, resource) do + case required_roles(resource) do + [] -> true + roles -> user_role(user, roles) + end + end + + @doc """ + Returns whether a resource has no explicit ownership or role restrictions. + """ + @spec unrestricted_resource?(map()) :: boolean() + def unrestricted_resource?(resource) do + is_nil(owner_id(resource)) and + required_roles(resource) == [] and + not explicitly_private_resource?(resource) + end + @doc """ Common policy check: environment check. @@ -106,15 +181,23 @@ defmodule AshUI.Authorization.Policies do authorize_if can_read_source(@resource.source) end """ - @spec can_read_source(map()) :: boolean() - def can_read_source(%{source: source}) when is_map(source) do - resource = Map.get(source, "resource") - action = Map.get(source, "action", :read) + @spec can_read_source(map(), term()) :: boolean() + def can_read_source(binding, actor \\ nil) + + def can_read_source(binding, actor) when is_map(binding) do + source = source_map(binding) - can_access_resource?(resource, action) + if map_size(source) == 0 do + true + else + resource = fetch_key(source, :resource) + action = fetch_key(source, :action) || :read + + can_access_resource?(resource, action, actor) + end end - def can_read_source(_), do: true + def can_read_source(_binding, _actor), do: true @doc """ Check if user can write to the binding source resource. @@ -127,15 +210,23 @@ defmodule AshUI.Authorization.Policies do authorize_if can_write_source(@resource.source) end """ - @spec can_write_source(map()) :: boolean() - def can_write_source(%{source: source}) when is_map(source) do - resource = Map.get(source, "resource") - action = Map.get(source, "action", :update) + @spec can_write_source(map(), term()) :: boolean() + def can_write_source(binding, actor \\ nil) - can_access_resource?(resource, action) + def can_write_source(binding, actor) when is_map(binding) do + source = source_map(binding) + + if map_size(source) == 0 do + true + else + resource = fetch_key(source, :resource) + action = fetch_key(source, :action) || :update + + can_access_resource?(resource, action, actor) + end end - def can_write_source(_), do: true + def can_write_source(_binding, _actor), do: true @doc """ Check if user can access a specific field on a resource. @@ -149,9 +240,26 @@ defmodule AshUI.Authorization.Policies do end """ @spec can_access_field(map(), atom() | String.t()) :: boolean() - def can_access_field(_resource, _field) do - # In production, would check field-level policies - true + def can_access_field(resource, field) do + field_name = to_string(field) + + hidden_fields = + resource + |> resource_value(:hidden_fields, resource_value(resource, :private_fields, [])) + |> List.wrap() + |> Enum.map(&to_string/1) + + allowed_fields = + resource + |> resource_value(:allowed_fields, []) + |> List.wrap() + |> Enum.map(&to_string/1) + + cond do + hidden_fields != [] -> field_name not in hidden_fields + allowed_fields != [] -> field_name in allowed_fields + true -> true + end end @doc """ @@ -181,13 +289,147 @@ defmodule AshUI.Authorization.Policies do Application.get_env(:ash_ui, :runtime_authorization_bypass, false) end + @doc """ + Evaluates record-scoped policy checks for AshUI resources. + + These checks mirror the policy modules used by the resource authorizers and + let runtime code fail closed when it already has a loaded record in hand. + """ + @spec allows_record_action?(term(), map(), atom()) :: boolean() | :unknown + def allows_record_action?(user, %Screen{} = screen, action) do + case action do + :mount -> ScreenPolicy.can_mount?(user, screen) + action when action in [:read] -> ScreenPolicy.can_read?(user, screen) + action when action in [:create, :update, :destroy] -> ScreenPolicy.can_manage?(user, screen) + _ -> :unknown + end + end + + def allows_record_action?(user, %Element{} = element, action) do + case action do + action when action in [:read] -> + ElementPolicy.can_read?(user, element) + + action when action in [:create, :update, :destroy] -> + ElementPolicy.can_manage?(user, element) + + _ -> + :unknown + end + end + + def allows_record_action?(user, %Binding{} = binding, action) do + case action do + action when action in [:read, :read_with_filter] -> + BindingPolicy.can_read?(user, binding) + + :write -> + BindingPolicy.can_write?(user, binding) + + action when action in [:create, :update, :destroy] -> + BindingPolicy.can_manage?(user, binding) + + _ -> + :unknown + end + end + + def allows_record_action?(_user, _record, _action), do: :unknown + # Private functions defp config_env do Application.get_env(:ash_ui, :env, :dev) end - defp can_access_resource?(nil, _action), do: true - defp can_access_resource?(resource, _action) when is_binary(resource), do: true - defp can_access_resource?(_resource, _action), do: false + defp can_access_resource?(nil, _action, _actor), do: false + + defp can_access_resource?(resource_ref, action, actor) do + context = %{actor: actor, ash_domains: configured_domains(), authorize?: true} + + with {:ok, %{resource: resource}} <- ResourceAccess.resolve(resource_ref, context), + action_name <- resolve_action_name(resource, action), + true <- is_nil(actor) or Ash.can?({resource, action_name}, actor, maybe_is: false) do + true + else + {:error, _reason} -> true + false -> false + end + rescue + _ -> true + end + + defp configured_domains do + Application.get_env(:ash_ui, :ash_domains, [AshUI.Domain]) + end + + defp resolve_action_name(resource, action) do + target = to_string(action || :read) + + case Enum.find(Ash.Resource.Info.actions(resource), fn existing -> + Atom.to_string(existing.name) == target + end) do + nil -> action || :read + existing -> existing.name + end + end + + defp normalize_role(role) when is_atom(role), do: role + + defp normalize_role(role) when is_binary(role) do + role + |> String.trim() + |> case do + "" -> nil + normalized -> String.to_atom(normalized) + end + end + + defp normalize_role(_role), do: nil + + defp resource_value(resource, key, default \\ nil) + + defp resource_value(resource, key, default) when is_map(resource) do + metadata = + case fetch_key(resource, :metadata) do + %{} = metadata -> metadata + _ -> %{} + end + + first_present([fetch_key(resource, key), fetch_key(metadata, key), default]) + end + + defp resource_value(_resource, _key, default), do: default + + defp source_map(binding) do + case first_present([fetch_key(binding, :source), %{}]) do + %{} = source -> source + _ -> %{} + end + end + + defp first_present(values) do + Enum.find_value(values, fn value -> + if is_nil(value), do: nil, else: {:ok, value} + end) + |> case do + {:ok, value} -> value + nil -> nil + end + end + + defp fetch_key(map, key) do + candidates = [key, to_string(key)] + + Enum.find_value(candidates, fn candidate -> + case Map.fetch(map, candidate) do + {:ok, value} -> {:ok, value} + :error -> nil + end + end) + |> case do + {:ok, value} -> value + nil -> nil + end + end end diff --git a/lib/ash_ui/authorization/runtime.ex b/lib/ash_ui/authorization/runtime.ex index eae90cb1..978df632 100644 --- a/lib/ash_ui/authorization/runtime.ex +++ b/lib/ash_ui/authorization/runtime.ex @@ -99,6 +99,7 @@ defmodule AshUI.Authorization.Runtime do else {:error, :no_user} -> emit_auth_telemetry(:action_no_user, context) + {:forbidden, %{reason: :unauthenticated, message: "You must be logged in", redirect: :login}} @@ -427,9 +428,29 @@ defmodule AshUI.Authorization.Runtime do end end - defp check_policy(_user, _resource, _action) do - # In production, would use Ash.Policy.Authorizer - :ok + defp check_policy(user, resource, action) do + case Policies.allows_record_action?(user, resource, action) do + true -> + :ok + + false -> + {:error, :policy_forbidden} + + :unknown -> + case policy_subject(resource, action) do + {:ok, subject, policy_action, opts} -> + if Ash.can?({subject, policy_action}, user, Keyword.put(opts, :maybe_is, false)) do + :ok + else + {:error, :policy_forbidden} + end + + :skip -> + :ok + end + end + rescue + _ -> :ok end defp get_resource_id(%{id: id}), do: id @@ -438,8 +459,54 @@ defmodule AshUI.Authorization.Runtime do defp format_action_error(reason) do case reason do :forbidden -> "You don't have permission to perform this action" + :policy_forbidden -> "This request is blocked by the configured resource policy" :invalid_params -> "Invalid parameters provided" _ -> "Action not allowed" end end + + defp policy_subject(%{__struct__: resource} = record, action) do + if ash_resource?(resource) do + {:ok, record, normalize_policy_action(resource, action), policy_opts(resource)} + else + :skip + end + end + + defp policy_subject(resource, action) when is_atom(resource) do + if ash_resource?(resource) do + {:ok, resource, normalize_policy_action(resource, action), policy_opts(resource)} + else + :skip + end + end + + defp policy_subject(_resource, _action), do: :skip + + defp normalize_policy_action(resource, :mount) do + case Enum.any?(Ash.Resource.Info.actions(resource), &(&1.name == :mount)) do + true -> :mount + false -> :read + end + end + + defp normalize_policy_action(resource, action) do + case Enum.find(Ash.Resource.Info.actions(resource), fn existing -> + existing.name == action + end) do + nil -> action + existing -> existing.name + end + end + + defp policy_opts(_resource) do + [] + end + + defp ash_resource?(module) do + Ash.Resource.Info.attributes(module) + true + rescue + _ -> false + end end diff --git a/lib/ash_ui/authorization/screen_policy.ex b/lib/ash_ui/authorization/screen_policy.ex index 3ce311da..c74580a4 100644 --- a/lib/ash_ui/authorization/screen_policy.ex +++ b/lib/ash_ui/authorization/screen_policy.ex @@ -37,24 +37,40 @@ defmodule AshUI.Authorization.ScreenPolicy do end @doc """ - Check if user can mount a specific screen. + Check if user can read a specific screen. """ - def can_mount?(user, screen) do + def can_read?(user, screen) do cond do Policies.runtime_authorization_bypass?() -> true - + not Policies.resource_active?(screen) -> false not Policies.user_active(user) -> false - - # Admins can mount any screen Policies.user_role(user, :admin) -> true + not Policies.role_allowed?(user, screen) -> false + Policies.screen_owner(user, screen) -> true + Policies.public_resource?(screen) -> true + Policies.unrestricted_resource?(screen) -> true + true -> false + end + end - # Public screens can be mounted by active users - Map.get(screen, :public, false) -> true + @doc """ + Check if user can mount a specific screen. + """ + def can_mount?(user, screen) do + can_read?(user, screen) + end - # Owners can mount their screens + @doc """ + Check if user can manage a specific screen. + """ + def can_manage?(user, screen) do + cond do + Policies.runtime_authorization_bypass?() -> true + not Policies.user_active(user) -> false + Policies.user_role(user, :admin) -> true + not Policies.role_allowed?(user, screen) -> false Policies.screen_owner(user, screen) -> true - - # Default deny + Policies.unrestricted_resource?(screen) -> true true -> false end end diff --git a/lib/ash_ui/authorization/subject.ex b/lib/ash_ui/authorization/subject.ex new file mode 100644 index 00000000..c4ed0060 --- /dev/null +++ b/lib/ash_ui/authorization/subject.ex @@ -0,0 +1,39 @@ +defmodule AshUI.Authorization.Subject do + @moduledoc """ + Normalizes Ash policy subjects into plain maps for policy checks. + """ + + @spec to_data(term()) :: map() + @doc """ + Converts a policy subject into a plain data map for authorization checks. + """ + def to_data(%Ash.Changeset{} = changeset) do + changeset + |> base_data() + |> Map.merge(normalize_map(changeset.arguments || %{})) + |> Map.merge(normalize_map(changeset.attributes || %{})) + end + + def to_data(%Ash.Query{resource: resource}), do: %{__resource__: resource} + def to_data(%struct{} = value) when struct != Ash.Query, do: Map.from_struct(value) + def to_data(value) when is_map(value), do: value + def to_data(resource) when is_atom(resource), do: %{__resource__: resource} + def to_data(_value), do: %{} + + defp base_data(%Ash.Changeset{data: nil}), do: %{} + defp base_data(%Ash.Changeset{data: data}) when is_map(data), do: to_data(data) + defp base_data(_changeset), do: %{} + + defp normalize_map(map) when is_map(map) do + Enum.into(map, %{}, fn {key, value} -> + normalized_key = + if is_atom(key) do + key + else + to_string(key) + end + + {normalized_key, value} + end) + end +end diff --git a/lib/ash_ui/compiler/incremental.ex b/lib/ash_ui/compiler/incremental.ex index b87d18d2..07775616 100644 --- a/lib/ash_ui/compiler/incremental.ex +++ b/lib/ash_ui/compiler/incremental.ex @@ -6,12 +6,11 @@ defmodule AshUI.Compiler.Incremental do affected resources when things change. """ + require Ash.Query require Logger - import Ecto.Query alias AshUI.Compiler alias AshUI.Domain - alias AshUI.Repo alias AshUI.Resources.Screen alias AshUI.Resources.Element alias AshUI.Resources.Binding @@ -42,11 +41,9 @@ defmodule AshUI.Compiler.Incremental do # Load elements and build relationships with {:ok, elements} <- load_screen_elements(screen), graph <- build_element_dependencies(graph, screen, elements), - graph <- build_binding_dependencies(graph, elements) do - case detect_circular_dependencies(graph) do - :ok -> {:ok, graph} - {:error, cycles} -> {:error, cycles} - end + graph <- build_binding_dependencies(graph, elements), + :ok <- detect_circular_dependencies(graph) do + {:ok, graph} end end @@ -213,13 +210,16 @@ defmodule AshUI.Compiler.Incremental do end defp load_screen_elements(%Screen{id: screen_id}) do - elements = + query = Element - |> where([element], element.screen_id == ^screen_id) - |> order_by([element], asc: element.position) - |> Repo.all() + |> Ash.Query.new() + |> Ash.Query.filter(screen_id == ^screen_id) + |> Ash.Query.sort(position: :asc) - {:ok, elements} + case Ash.read(query, domain: Domain) do + {:ok, elements} -> {:ok, elements} + {:error, _} -> {:ok, []} + end end defp build_element_dependencies(graph, screen, elements) do @@ -259,9 +259,15 @@ defmodule AshUI.Compiler.Incremental do end defp get_element_bindings(%Element{id: element_id}) do - Binding - |> where([binding], binding.element_id == ^element_id) - |> Repo.all() + query = + Binding + |> Ash.Query.new() + |> Ash.Query.filter(element_id == ^element_id) + + case Ash.read(query, domain: Domain) do + {:ok, bindings} -> bindings + {:error, _} -> [] + end end defp find_cycles(graph) do diff --git a/lib/ash_ui/liveview/error_handler.ex b/lib/ash_ui/liveview/error_handler.ex index 3b933aaf..350c8ea8 100644 --- a/lib/ash_ui/liveview/error_handler.ex +++ b/lib/ash_ui/liveview/error_handler.ex @@ -80,7 +80,6 @@ defmodule AshUI.LiveView.ErrorHandler do # Store error in binding state for UI to handle updated_socket = store_binding_error(socket, binding, error_info) - # Return error placeholder value {:error, reason, updated_socket} end @@ -156,15 +155,10 @@ defmodule AshUI.LiveView.ErrorHandler do @spec handle_runtime_error(Exception.t(), list(), Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t() def handle_runtime_error(exception, stacktrace, socket) do - error_info = %{ - type: :runtime, - reason: exception, - message: Exception.message(exception), - timestamp: DateTime.utc_now(), - context: - base_context(socket) - |> Map.put(:stacktrace, format_stacktrace(stacktrace)) - } + error_info = + build_error_info(:runtime, exception, socket, %{ + stacktrace: format_stacktrace(stacktrace) + }) # Log runtime error log_runtime_error(error_info) @@ -321,20 +315,36 @@ defmodule AshUI.LiveView.ErrorHandler do # Private functions defp build_error_info(type, reason, socket, extra_context \\ %{}) do - context = Map.merge(base_context(socket), normalize_context(extra_context)) + extra_context = + case extra_context do + extra when is_list(extra) -> Enum.into(extra, %{}) + extra when is_map(extra) -> extra + end + + base_context = %{ + screen_id: get_screen_id(socket), + user_id: get_user_id(socket), + session_id: get_session_id(socket) + } %{ type: type, reason: reason, message: format_error_message(reason), timestamp: DateTime.utc_now(), - context: context + context: Map.merge(base_context, extra_context) } end defp format_error_message(reason) when is_binary(reason), do: reason defp format_error_message(reason), do: inspect(reason) + defp format_stacktrace(stacktrace) do + Exception.format_stacktrace(stacktrace) + rescue + FunctionClauseError -> inspect(stacktrace) + end + defp get_screen_id(socket) do case socket.assigns[:ash_ui_screen] do %{id: id} -> id @@ -399,8 +409,6 @@ defmodule AshUI.LiveView.ErrorHandler do end defp emit_error_telemetry(error_info) do - context = error_info.context || %{} - Telemetry.execute( [:ash_ui, :error, error_info.type], %{count: 1}, @@ -408,8 +416,8 @@ defmodule AshUI.LiveView.ErrorHandler do error: inspect(error_info.reason), reason: inspect(error_info.reason), resource_type: :screen, - screen_id: Map.get(context, :screen_id) || Map.get(context, "screen_id"), - user_id: Map.get(context, :user_id) || Map.get(context, "user_id"), + screen_id: error_info.context.screen_id, + user_id: error_info.context.user_id, status: :error } ) @@ -432,7 +440,7 @@ defmodule AshUI.LiveView.ErrorHandler do defp store_binding_error(socket, binding, error_info) do binding_errors = Map.get(socket.assigns, :ash_ui_binding_errors, %{}) - binding_id = Map.get(binding, :id) || Map.get(binding, "id") || "unknown_binding" + binding_id = Map.get(binding, :id) || Map.get(binding, "id") updated = Map.put(binding_errors, binding_id, error_info) Phoenix.Component.assign(socket, :ash_ui_binding_errors, updated) end @@ -455,7 +463,7 @@ defmodule AshUI.LiveView.ErrorHandler do {:ok, result} -> {:ok, result} - {:error, _reason} -> + {:error, _reason} = _error -> delay = min((base_delay * :math.pow(2, attempt)) |> trunc(), max_delay) Process.sleep(delay) retry_with_backoff(operation, attempt + 1, max_attempts, base_delay, max_delay) @@ -478,26 +486,4 @@ defmodule AshUI.LiveView.ErrorHandler do end defp default_fallback(_), do: :error - - defp base_context(socket) do - %{ - screen_id: get_screen_id(socket), - user_id: get_user_id(socket), - session_id: get_session_id(socket) - } - end - - defp normalize_context(extra_context) when is_list(extra_context), do: Map.new(extra_context) - defp normalize_context(extra_context) when is_map(extra_context), do: extra_context - defp normalize_context(_extra_context), do: %{} - - defp format_stacktrace(stacktrace) when is_list(stacktrace) do - try do - Exception.format_stacktrace(stacktrace) - rescue - _ -> inspect(stacktrace) - end - end - - defp format_stacktrace(stacktrace), do: inspect(stacktrace) end diff --git a/lib/ash_ui/liveview/event_handler.ex b/lib/ash_ui/liveview/event_handler.ex index ff4eeaaa..e47ce5fc 100644 --- a/lib/ash_ui/liveview/event_handler.ex +++ b/lib/ash_ui/liveview/event_handler.ex @@ -73,6 +73,11 @@ defmodule AshUI.LiveView.EventHandler do {:ok, socket} <- write_value(binding, value, socket, context) do {:noreply, socket} else + {:error, reason, error_socket} -> + Logger.error("Value change failed: #{inspect(reason)}") + socket = assign_flash(error_socket, :error, "Update failed: #{inspect(reason)}") + {:noreply, socket} + {:error, reason} -> Logger.error("Value change failed: #{inspect(reason)}") socket = assign_flash(socket, :error, "Update failed: #{inspect(reason)}") @@ -95,10 +100,9 @@ defmodule AshUI.LiveView.EventHandler do def handle_action_event(event_params, socket) do action_id = Map.get(event_params, "action_id") event_data = Map.get(event_params, "data", %{}) - context = build_event_context(socket) - with :ok <- authorize_action_context(context), - {:ok, binding} <- find_action_binding(action_id, socket), + with {:ok, binding} <- find_action_binding(action_id, socket), + context <- build_event_context(socket), {:ok, result} <- execute_action(binding, event_data, socket, context), socket <- handle_action_result(result, socket) do {:reply, %{status: :ok}, socket} @@ -183,7 +187,7 @@ defmodule AshUI.LiveView.EventHandler do """ @spec validate_event_data(map(), String.t()) :: :ok | {:error, term()} def validate_event_data(event_data, expected_type) do - with :ok <- validate_required_fields(event_data, expected_type), + with :ok <- validate_required_fields(event_data), :ok <- validate_event_type(event_data, expected_type) do :ok end @@ -266,15 +270,14 @@ defmodule AshUI.LiveView.EventHandler do %{ user_id: get_user_id(socket), user: socket.assigns[:ash_ui_user], + authorize?: true, params: socket.assigns[:ash_ui_params] || %{}, assigns: socket.assigns, - socket: socket + socket: socket, + ash_domains: Map.get(socket.assigns, :ash_ui_domains, Application.get_env(:ash_ui, :ash_domains, [AshUI.Domain])) } end - defp authorize_action_context(%{user_id: nil}), do: {:error, :unauthorized} - defp authorize_action_context(_context), do: :ok - defp get_user_id(socket) do case socket.assigns[:ash_ui_user] do %{id: id} -> id @@ -285,13 +288,14 @@ defmodule AshUI.LiveView.EventHandler do defp write_value(binding, value, socket, context) do case BidirectionalBinding.write_binding(binding, value, socket, context) do {:ok, updated_socket, _result} -> {:ok, updated_socket} - {:error, reason, _error_socket} -> {:error, reason} + {:error, reason, error_socket} -> {:error, reason, error_socket} end end defp execute_action(binding, event_data, _socket, context) do case ActionBinding.execute_action(binding, event_data, context) do {:ok, result} -> {:ok, result} + {:error, %{errors: [%{"message" => "Unauthorized"} | _]}} -> {:error, :unauthorized} {:error, reason} -> {:error, reason} end end @@ -314,13 +318,8 @@ defmodule AshUI.LiveView.EventHandler do %{socket | assigns: Map.put(socket.assigns, :flash, updated_flash)} end - defp validate_required_fields(event_data, expected_type) do - required = - case expected_type do - "change" -> ["target", "data"] - _ -> ["target"] - end - + defp validate_required_fields(event_data) do + required = ["target", "data"] missing = Enum.reject(required, &Map.has_key?(event_data, &1)) if missing == [] do diff --git a/lib/ash_ui/liveview/hooks.ex b/lib/ash_ui/liveview/hooks.ex index a0a2dc0e..9a565b90 100644 --- a/lib/ash_ui/liveview/hooks.ex +++ b/lib/ash_ui/liveview/hooks.ex @@ -9,6 +9,7 @@ defmodule AshUI.LiveView.Hooks do require Logger alias AshUI.LiveView.Integration + alias AshUI.LiveView.UpdateIntegration @doc """ on_mount hook for initializing Ash UI screens. @@ -102,7 +103,7 @@ defmodule AshUI.LiveView.Hooks do """ def on_unmount(socket) do # Unsubscribe from all Ash resource notifications - cleanup_subscriptions(socket) + UpdateIntegration.cleanup_subscriptions(socket) # Emit unmount telemetry screen_id = get_screen_id(socket) @@ -182,12 +183,7 @@ defmodule AshUI.LiveView.Hooks do end """ def cleanup_session(socket) do - # Clean up any session-specific state - subscriptions = Map.get(socket.assigns, :ash_ui_subscriptions, []) - - Enum.each(subscriptions, fn sub -> - unsubscribe_from_resource(sub) - end) + UpdateIntegration.cleanup_subscriptions(socket) socket |> Phoenix.Component.assign(:ash_ui_subscriptions, []) @@ -219,18 +215,4 @@ defmodule AshUI.LiveView.Hooks do defp assign_error(socket, reason) do Phoenix.Component.assign(socket, :ash_ui_error, reason) end - - defp cleanup_subscriptions(socket) do - subscriptions = Map.get(socket.assigns, :ash_ui_subscriptions, []) - - Enum.each(subscriptions, fn sub -> - unsubscribe_from_resource(sub) - end) - end - - defp unsubscribe_from_resource(_subscription) do - # Unsubscribe from Ash.Notifier - # In production, would call Ash.Notifier.unsubscribe/1 - :ok - end end diff --git a/lib/ash_ui/liveview/lifecycle.ex b/lib/ash_ui/liveview/lifecycle.ex index 3712e646..bc73cd9d 100644 --- a/lib/ash_ui/liveview/lifecycle.ex +++ b/lib/ash_ui/liveview/lifecycle.ex @@ -380,7 +380,22 @@ defmodule AshUI.LiveView.Lifecycle do end if needs_refresh do - UpdateIntegration.refresh_bindings(socket) + refreshed_socket = + case UpdateIntegration.refresh_bindings(socket) do + {:noreply, refreshed_socket} -> refreshed_socket + end + + update_in(refreshed_socket.assigns[:ash_ui_session_state], fn + nil -> nil + session_state -> Map.put(session_state, :bindings_need_refresh, false) + end) + |> then(fn session_state -> + if is_nil(session_state) do + refreshed_socket + else + Phoenix.Component.assign(refreshed_socket, :ash_ui_session_state, session_state) + end + end) else socket end diff --git a/lib/ash_ui/liveview/liveview_integration.ex b/lib/ash_ui/liveview/liveview_integration.ex index 6c147ecd..488e60af 100644 --- a/lib/ash_ui/liveview/liveview_integration.ex +++ b/lib/ash_ui/liveview/liveview_integration.ex @@ -7,12 +7,16 @@ defmodule AshUI.LiveView.Integration do """ require Logger + require Ash.Query alias AshUI.Compiler - alias AshUI.Authorization.ScreenPolicy + alias AshUI.Authorization.BindingPolicy + alias AshUI.Domain + alias AshUI.Authorization.Runtime alias AshUI.Resources.Screen alias AshUI.Resources.Binding alias AshUI.Runtime.BindingEvaluator + alias AshUI.LiveView.UpdateIntegration alias AshUI.Rendering.IURAdapter alias AshUI.Telemetry @@ -49,7 +53,8 @@ defmodule AshUI.LiveView.Integration do :ok <- authorize_screen(screen, user), {:ok, iur} <- compile_screen(screen), {:ok, bindings} <- evaluate_bindings(screen, socket, user, params), - socket <- assign_screen_state(socket, screen, iur, bindings, user) do + socket <- assign_screen_state(socket, screen, iur, bindings, user, params), + socket <- UpdateIntegration.sync_binding_subscriptions(socket) do {:ok, socket} else {:error, :unauthorized} -> @@ -72,7 +77,10 @@ defmodule AshUI.LiveView.Integration do """ @spec authorize_screen(Screen.t(), term()) :: :ok | {:error, :unauthorized} def authorize_screen(%Screen{} = screen, user) do - if ScreenPolicy.can_mount?(user, screen), do: :ok, else: {:error, :unauthorized} + case Runtime.check_mount_authorization(user, screen) do + :authorized -> :ok + _ -> {:error, :unauthorized} + end end @doc """ @@ -109,7 +117,7 @@ defmodule AshUI.LiveView.Integration do context = build_evaluation_context(socket, user, params) screen - |> load_screen_bindings() + |> load_screen_bindings(user) |> evaluate_batch_bindings(context) end @@ -148,7 +156,7 @@ defmodule AshUI.LiveView.Integration do end defp load_screen_by_primary_key(screen_id, user) do - case Ash.get(Screen, screen_id, actor: user, authorize?: true) do + case Ash.get(Screen, screen_id, action: :mount, actor: user, domain: Domain, authorize?: true) do {:ok, screen} -> {:ok, screen} {:error, reason} -> {:error, reason} end @@ -158,7 +166,12 @@ defmodule AshUI.LiveView.Integration do end defp load_screen_by_name(name) do - case AshUI.Data.read_one(Screen, filter: [name: name], authorize?: false) do + query = + Screen + |> Ash.Query.new() + |> Ash.Query.filter(name == ^name) + + case Ash.read_one(query, domain: Domain) do {:ok, %Screen{} = screen} -> {:ok, screen} {:ok, nil} -> {:error, :not_found} {:error, reason} -> {:error, reason} @@ -169,9 +182,16 @@ defmodule AshUI.LiveView.Integration do %{ user_id: get_user_id(user), user: user, + authorize?: true, params: params, assigns: socket.assigns, - socket: socket + socket: socket, + ash_domains: + Map.get( + socket.assigns, + :ash_ui_domains, + Application.get_env(:ash_ui, :ash_domains, [Domain]) + ) } end @@ -184,12 +204,20 @@ defmodule AshUI.LiveView.Integration do end end - defp load_screen_bindings(%Screen{} = screen) do - # Load all bindings for this screen - # In production, would use Ash.read/2 with proper filtering - case Ash.read(Binding, filter: [screen_id: screen.id], authorize?: true) do - {:ok, bindings} -> bindings - {:error, _} -> [] + defp load_screen_bindings(%Screen{} = screen, user) do + query = + Binding + |> Ash.Query.new() + |> Ash.Query.filter(screen_id == ^screen.id) + + case Ash.read(query, actor: user, domain: Domain, authorize?: true) do + {:ok, bindings} -> + bindings + |> Enum.map(&Map.put(&1, :screen, screen)) + |> Enum.filter(&binding_readable?(&1, user)) + + {:error, _} -> + [] end rescue _ -> [] @@ -200,25 +228,30 @@ defmodule AshUI.LiveView.Integration do Enum.reduce(bindings, %{}, fn binding, acc -> case BindingEvaluator.evaluate(binding, context) do {:ok, value} -> - Map.put(acc, binding.id, value) + Map.put(acc, binding.id, build_binding_state(binding, value: value, error: nil)) {:error, reason} -> Logger.warning("Binding #{binding.id} evaluation failed: #{inspect(reason)}") - # Store error state for UI to handle - Map.put(acc, binding.id, {:error, reason}) + Map.put(acc, binding.id, build_binding_state(binding, value: nil, error: reason)) end end) {:ok, results} end - defp assign_screen_state(socket, screen, iur, bindings, user) do + defp assign_screen_state(socket, screen, iur, bindings, user, params) do socket |> Phoenix.Component.assign(:ash_ui_screen, screen) |> Phoenix.Component.assign(:ash_ui_iur, iur) |> Phoenix.Component.assign(:ash_ui_bindings, bindings) + |> Phoenix.Component.assign(:ash_ui_params, params) + |> Phoenix.Component.assign( + :ash_ui_domains, + Application.get_env(:ash_ui, :ash_domains, [Domain]) + ) |> Phoenix.Component.assign(:ash_ui_user, user) |> Phoenix.Component.assign(:ash_ui_loaded_at, DateTime.utc_now()) + |> sync_runtime_binding_assigns(bindings) end @doc """ @@ -254,4 +287,50 @@ defmodule AshUI.LiveView.Integration do def emit_telemetry(event, metadata, measurements \\ %{}) do Telemetry.emit(:screen, event, measurements, metadata) end + + defp build_binding_state(binding, attrs) do + %{ + id: binding.id, + source: binding.source || %{}, + target: binding.target, + binding_type: binding.binding_type, + transform: binding.transform || %{}, + metadata: binding.metadata || %{}, + screen_id: binding.screen_id, + element_id: binding.element_id, + value: Keyword.get(attrs, :value), + error: Keyword.get(attrs, :error), + updated_at: System.system_time(:millisecond) + } + end + + defp sync_runtime_binding_assigns(socket, bindings) do + ash_ui = Map.get(socket.assigns, :ash_ui, %{}) + runtime_bindings = Map.get(ash_ui, :bindings, %{}) + + updated_runtime_bindings = + Enum.reduce(bindings, runtime_bindings, fn {_binding_id, binding_state}, acc -> + case Map.get(binding_state, :target) || Map.get(binding_state, "target") do + nil -> + acc + + target -> + Map.put(acc, target, %{ + "value" => Map.get(binding_state, :value), + "error" => Map.get(binding_state, :error), + "updated_at" => Map.get(binding_state, :updated_at) + }) + end + end) + + Phoenix.Component.assign( + socket, + :ash_ui, + Map.put(ash_ui, :bindings, updated_runtime_bindings) + ) + end + + defp binding_readable?(binding, user) do + BindingPolicy.can_read?(user, binding) + end end diff --git a/lib/ash_ui/liveview/update_integration.ex b/lib/ash_ui/liveview/update_integration.ex index 807db5ac..e9c3d25a 100644 --- a/lib/ash_ui/liveview/update_integration.ex +++ b/lib/ash_ui/liveview/update_integration.ex @@ -8,9 +8,11 @@ defmodule AshUI.LiveView.UpdateIntegration do require Logger - alias AshUI.Runtime.BindingEvaluator alias AshUI.LiveView.Integration - alias AshUI.Resources.Screen + alias AshUI.Runtime.BindingEvaluator + alias AshUI.Runtime.ResourceAccess + + @subscription_table :ash_ui_liveview_subscriptions @type subscription :: %{ id: String.t(), @@ -31,69 +33,83 @@ defmodule AshUI.LiveView.UpdateIntegration do ## Returns * `{:ok, subscription}` - Subscription created - * `{:error, reason}` - Subscription failed - - ## Examples - - {:ok, sub} = UpdateIntegration.subscribe(socket, User.Profile, user_id: user.id) """ - @spec subscribe(Phoenix.LiveView.Socket.t(), module(), keyword()) :: {:ok, subscription()} | {:error, term()} + @spec subscribe(Phoenix.LiveView.Socket.t(), module(), keyword()) :: + {:ok, subscription()} def subscribe(socket, resource, opts \\ []) do - filter = Keyword.get(opts, :filter, %{}) + filter = opts |> Keyword.get(:filter, %{}) |> Enum.into(%{}) action = Keyword.get(opts, :action, :update) + subscription = build_subscription(resource, action, filter) - subscription = %{ - id: generate_subscription_id(resource, filter), - resource: resource, - action: action, - filter: filter - } + :ok = subscribe_to_resource(resource, subscription) + store_subscription(socket, subscription) + {:ok, subscription} + end - case subscribe_to_resource(resource, subscription) do - :ok -> - track_subscription(socket, subscription) - {:ok, subscription} + @doc """ + Registers subscriptions for all resource-backed bindings currently assigned + to the socket and returns the socket with the subscription list assigned. + """ + @spec sync_binding_subscriptions(Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t() + def sync_binding_subscriptions(socket) do + bindings = socket.assigns[:ash_ui_bindings] || %{} + existing_subscriptions = subscriptions(socket) + + {created, all_subscriptions} = + bindings + |> binding_resources(socket) + |> Enum.reduce({[], existing_subscriptions}, fn resource, {created, subscriptions} -> + if Enum.any?(subscriptions, &(&1.resource == resource)) do + {created, subscriptions} + else + subscription = build_subscription(resource, :update, %{}) + :ok = subscribe_to_resource(resource, subscription) + store_subscription(socket, subscription) + {[subscription | created], [subscription | subscriptions]} + end + end) - {:error, reason} -> - {:error, reason} + if created == [] do + Phoenix.Component.assign(socket, :ash_ui_subscriptions, existing_subscriptions) + else + Phoenix.Component.assign(socket, :ash_ui_subscriptions, merge_unique_subscriptions(all_subscriptions)) end end @doc """ - Unsubscribes from a resource change notification. - - ## Examples + Returns the subscriptions currently tracked for the LiveView session. + """ + @spec subscriptions(Phoenix.LiveView.Socket.t()) :: [subscription()] + def subscriptions(socket) do + assigned = Map.get(socket.assigns, :ash_ui_subscriptions, []) + + socket + |> session_scope() + |> registry_subscriptions() + |> Kernel.++(assigned) + |> merge_unique_subscriptions() + end - UpdateIntegration.unsubscribe(socket, subscription) + @doc """ + Unsubscribes from a resource change notification. """ - @spec unsubscribe(Phoenix.LiveView.Socket.t(), subscription()) :: :ok | {:error, term()} + @spec unsubscribe(Phoenix.LiveView.Socket.t(), subscription()) :: :ok def unsubscribe(socket, subscription) do - case unsubscribe_from_resource(subscription) do - :ok -> - remove_subscription(socket, subscription) - :ok - - {:error, reason} -> - {:error, reason} - end + :ok = unsubscribe_from_resource(subscription) + delete_subscription(socket, subscription) + :ok end @doc """ Handles resource change notifications from Ash.Notifier. This should be called from LiveView's `handle_info/2` callback. - - ## Examples - - def handle_info({:ash_change, notification}, socket) do - AshUI.LiveView.UpdateIntegration.handle_resource_change(notification, socket) - end """ @spec handle_resource_change(map(), Phoenix.LiveView.Socket.t()) :: update_result() def handle_resource_change(notification, socket) do bindings = socket.assigns[:ash_ui_bindings] || %{} - with {:ok, affected_bindings} <- find_affected_bindings(notification, bindings), + with {:ok, affected_bindings} <- find_affected_bindings(notification, bindings, socket), {:ok, updated_values} <- reevaluate_bindings(affected_bindings, socket), socket <- update_socket_assigns(socket, updated_values), {:ok, socket} <- maybe_trigger_render(socket) do @@ -107,26 +123,11 @@ defmodule AshUI.LiveView.UpdateIntegration do @doc """ Batches multiple updates for performance. - - Instead of triggering a re-render for each binding change, - collects changes and applies them together. - - ## Examples - - UpdateIntegration.batch_updates(socket, fn socket -> - # Multiple updates here - socket - end) """ @spec batch_updates(Phoenix.LiveView.Socket.t(), fun()) :: update_result() def batch_updates(socket, update_fn) when is_function(update_fn, 1) do - # Mark the start of a batch socket = Phoenix.Component.assign(socket, :_ash_ui_batch_mode, true) - - # Apply all updates socket = update_fn.(socket) - - # Clear batch mode and trigger single render socket = Phoenix.Component.assign(socket, :_ash_ui_batch_mode, false) {:noreply, socket} @@ -134,43 +135,18 @@ defmodule AshUI.LiveView.UpdateIntegration do @doc """ Handles subscription messages from Ash.Notifier. - - Routes different notification types to appropriate handlers. - - ## Notification Types - * `{:created, resource}` - New resource created - * `{:updated, resource}` - Resource updated - * `{:destroyed, resource}` - Resource deleted - - ## Examples - - def handle_info({:ash_notification, notification}, socket) do - AshUI.LiveView.UpdateIntegration.handle_notification(notification, socket) - end """ @spec handle_notification(tuple(), Phoenix.LiveView.Socket.t()) :: update_result() def handle_notification({:created, resource}, socket) do - handle_resource_change(%{ - type: :created, - resource: resource, - timestamp: DateTime.utc_now() - }, socket) + handle_resource_change(%{type: :created, resource: resource, timestamp: DateTime.utc_now()}, socket) end def handle_notification({:updated, resource}, socket) do - handle_resource_change(%{ - type: :updated, - resource: resource, - timestamp: DateTime.utc_now() - }, socket) + handle_resource_change(%{type: :updated, resource: resource, timestamp: DateTime.utc_now()}, socket) end def handle_notification({:destroyed, resource}, socket) do - handle_resource_change(%{ - type: :destroyed, - resource: resource, - timestamp: DateTime.utc_now() - }, socket) + handle_resource_change(%{type: :destroyed, resource: resource, timestamp: DateTime.utc_now()}, socket) end def handle_notification(unknown, socket) do @@ -180,10 +156,6 @@ defmodule AshUI.LiveView.UpdateIntegration do @doc """ Re-evaluates all bindings for a screen after data changes. - - ## Examples - - UpdateIntegration.refresh_bindings(socket) """ @spec refresh_bindings(Phoenix.LiveView.Socket.t()) :: update_result() def refresh_bindings(socket) do @@ -191,121 +163,157 @@ defmodule AshUI.LiveView.UpdateIntegration do user = socket.assigns[:ash_ui_user] params = socket.assigns[:ash_ui_params] || %{} - case screen do - %Screen{} -> - case refresh_screen_bindings(screen, socket, user, params) do - {:ok, bindings} -> - socket = Phoenix.Component.assign(socket, :ash_ui_bindings, bindings) - {:noreply, socket} + cond do + not match?(%AshUI.Resources.Screen{}, screen) or is_nil(user) -> + {:noreply, socket} + + true -> + {:ok, bindings} = Integration.evaluate_bindings(screen, socket, user, params) - {:error, reason} -> - Logger.error("Failed to refresh bindings: #{inspect(reason)}") - {:noreply, socket} - end + socket = + socket + |> Phoenix.Component.assign(:ash_ui_bindings, bindings) + |> sync_runtime_binding_assigns(bindings) + |> sync_binding_subscriptions() - _ -> {:noreply, socket} end end @doc """ Filters notifications to bound resources only. - - Ensures we only process notifications for resources - that are actually bound to the current screen. - - ## Examples - - if UpdateIntegration.relevant_notification?(notification, socket) do - # process notification - end """ @spec relevant_notification?(map(), Phoenix.LiveView.Socket.t()) :: boolean() def relevant_notification?(notification, socket) do - subscriptions = get_subscriptions(socket) resource = get_notification_resource(notification) - Enum.any?(subscriptions, fn sub -> - sub.resource == resource + Enum.any?(subscriptions(socket), fn subscription -> + subscription.resource == resource end) end - # Private functions + @doc """ + Cleanup all subscriptions on unmount. - defp generate_subscription_id(resource, filter) do - "#{inspect(resource)}_#{:erlang.phash2(filter)}" - end + Should be called from LiveView's terminate/2 callback. + """ + @spec cleanup_subscriptions(Phoenix.LiveView.Socket.t()) :: :ok + def cleanup_subscriptions(socket) do + subscriptions(socket) + |> Enum.each(&unsubscribe_from_resource/1) - defp subscribe_to_resource(_resource, _subscription) do - # Subscribe to Ash.Notifier - # In production, would call Ash.Notifier.subscribe/2 - try do - # Ash.Notifier.subscribe(subscription.resource, subscription.filter) - :ok - rescue - e -> {:error, {:subscription_failed, e}} - end + clear_scope(socket) + + :ok end - defp unsubscribe_from_resource(_subscription) do - # Unsubscribe from Ash.Notifier - # In production, would call Ash.Notifier.unsubscribe/1 - try do - # Ash.Notifier.unsubscribe(subscription.resource) - :ok - rescue - e -> {:error, {:unsubscribe_failed, e}} - end + defp build_subscription(resource, action, filter) do + %{ + id: generate_subscription_id(resource, filter, action), + resource: resource, + action: action, + filter: filter + } end - defp track_subscription(socket, subscription) do - subscriptions = Map.get(socket.assigns, :ash_ui_subscriptions, []) - updated = [subscription | subscriptions] - Phoenix.Component.assign(socket, :ash_ui_subscriptions, updated) + defp generate_subscription_id(resource, filter, action) do + "#{inspect(resource)}_#{action}_#{:erlang.phash2(filter)}" end - defp remove_subscription(socket, subscription) do - subscriptions = Map.get(socket.assigns, :ash_ui_subscriptions, []) - updated = Enum.reject(subscriptions, fn sub -> sub.id == subscription.id end) - Phoenix.Component.assign(socket, :ash_ui_subscriptions, updated) + defp subscribe_to_resource(_resource, _subscription) do + # Ash.Notifier integration is still an external dependency. + # We track subscriptions per LiveView session so the reactivity pipeline + # behaves correctly once real notifications are delivered. + :ok end - defp get_subscriptions(socket) do - Map.get(socket.assigns, :ash_ui_subscriptions, []) + defp unsubscribe_from_resource(_subscription), do: :ok + + defp binding_resources(bindings, socket) do + bindings + |> normalize_bindings() + |> Enum.map(&binding_resource(&1, socket)) + |> Enum.reject(&is_nil/1) + |> Enum.uniq() end - defp get_notification_resource(%{resource: resource}), do: resource - defp get_notification_resource(_), do: nil + defp find_affected_bindings(notification, bindings, socket) do + resource = get_notification_resource(notification) - defp find_affected_bindings(_notification, bindings) do - # Find bindings that reference the changed resource affected = - Enum.filter(bindings, fn {_id, _value} -> - # In production, would check if binding source matches notification resource - true - end) + bindings + |> normalize_bindings() + |> Enum.filter(&binding_matches_resource?(&1, resource, socket)) {:ok, affected} end + defp normalize_bindings(bindings) when is_map(bindings) do + Enum.reduce(bindings, [], fn + {binding_key, binding_state}, acc when is_map(binding_state) -> + binding_state = + binding_state + |> Map.put_new(:id, binding_key) + |> Map.put(:binding_key, binding_key) + + [binding_state | acc] + + _other, acc -> + acc + end) + end + + defp normalize_bindings(bindings) when is_list(bindings) do + Enum.filter(bindings, &is_map/1) + end + + defp normalize_bindings(_other), do: [] + + defp binding_matches_resource?(_binding, nil, _socket), do: false + + defp binding_matches_resource?(binding, resource, socket) do + binding_type = Map.get(binding, :binding_type) || Map.get(binding, "binding_type") + + if binding_type in [:action, "action"] do + false + else + binding_resource(binding, socket) == resource + end + end + + defp binding_resource(binding, socket) do + source = Map.get(binding, :source) || Map.get(binding, "source") || %{} + resource_ref = Map.get(source, :resource) || Map.get(source, "resource") + + cond do + is_nil(resource_ref) -> + nil + + is_atom(resource_ref) -> + resource_ref + + true -> + case ResourceAccess.resolve(resource_ref, build_evaluation_context(socket)) do + {:ok, %{resource: resource}} -> resource + {:error, _reason} -> nil + end + end + end + defp reevaluate_bindings(affected_bindings, socket) do context = build_evaluation_context(socket) results = - Enum.reduce(affected_bindings, %{}, fn {binding_id, _value}, acc -> - case get_binding_by_id(binding_id, socket) do - {:ok, binding} -> - case BindingEvaluator.evaluate(binding, context) do - {:ok, value} -> - Map.put(acc, binding_id, value) - - {:error, _reason} -> - # Keep old value on error - acc - end - - :error -> - acc + Enum.reduce(affected_bindings, %{}, fn binding, acc -> + case BindingEvaluator.evaluate(binding, context) do + {:ok, value} -> + Map.put(acc, storage_key(binding), updated_binding_state(binding, value, nil)) + + {:error, reason} -> + Logger.warning("Binding #{inspect(binding_id(binding))} re-evaluation failed: #{inspect(reason)}") + + current_value = Map.get(binding, :value) || Map.get(binding, "value") + Map.put(acc, storage_key(binding), updated_binding_state(binding, current_value, reason)) end end) @@ -316,9 +324,11 @@ defmodule AshUI.LiveView.UpdateIntegration do %{ user_id: get_user_id(socket), user: socket.assigns[:ash_ui_user], + authorize?: true, params: socket.assigns[:ash_ui_params] || %{}, assigns: socket.assigns, - socket: socket + socket: socket, + ash_domains: Map.get(socket.assigns, :ash_ui_domains, Application.get_env(:ash_ui, :ash_domains, [AshUI.Domain])) } end @@ -329,65 +339,125 @@ defmodule AshUI.LiveView.UpdateIntegration do end end - defp get_binding_by_id(binding_id, socket) do - bindings = socket.assigns[:ash_ui_bindings] || %{} - - case Map.get(bindings, binding_id) do - nil -> - :error - - binding when is_map(binding) -> - {:ok, Map.put_new(binding, :id, binding_id)} - - _other -> - :error - end + defp updated_binding_state(binding, value, error) do + binding + |> Map.drop([:binding_key]) + |> Map.put(:value, value) + |> Map.put(:error, error) + |> Map.put(:updated_at, System.system_time(:millisecond)) end + defp update_socket_assigns(socket, updated_values) when map_size(updated_values) == 0, do: socket + defp update_socket_assigns(socket, updated_values) do current_bindings = socket.assigns[:ash_ui_bindings] || %{} updated_bindings = Map.merge(current_bindings, updated_values) - Phoenix.Component.assign(socket, :ash_ui_bindings, updated_bindings) + + socket + |> Phoenix.Component.assign(:ash_ui_bindings, updated_bindings) + |> sync_runtime_binding_assigns(updated_values) end - defp maybe_trigger_render(socket) do - batch_mode = Map.get(socket.assigns, :_ash_ui_batch_mode, false) + defp sync_runtime_binding_assigns(socket, bindings) do + ash_ui = Map.get(socket.assigns, :ash_ui, %{}) + runtime_bindings = Map.get(ash_ui, :bindings, %{}) - if batch_mode do + updated_runtime_bindings = + Enum.reduce(bindings, runtime_bindings, fn {_binding_id, binding_state}, acc -> + case Map.get(binding_state, :target) || Map.get(binding_state, "target") do + nil -> + acc + + target -> + Map.put(acc, target, %{ + "value" => Map.get(binding_state, :value), + "error" => Map.get(binding_state, :error), + "updated_at" => Map.get(binding_state, :updated_at) + }) + end + end) + + Phoenix.Component.assign(socket, :ash_ui, Map.put(ash_ui, :bindings, updated_runtime_bindings)) + end + + defp maybe_trigger_render(socket) do + if Map.get(socket.assigns, :_ash_ui_batch_mode, false) do {:ok, socket} else - # Trigger re-render {:ok, socket} end end - @doc """ - Cleanup all subscriptions on unmount. + defp session_scope(socket) do + {self(), Map.get(socket.assigns, :ash_ui_session_id) || Map.get(socket.assigns, :ash_ui_session_key) || :default} + end - Should be called from LiveView's terminate/2 callback. + defp store_subscription(socket, subscription) do + table = ensure_subscription_table() + scope = session_scope(socket) - ## Examples + delete_subscription(socket, subscription) + :ets.insert(table, {scope, subscription}) + end - def terminate(reason, socket) do - AshUI.LiveView.UpdateIntegration.cleanup_subscriptions(socket) - end - """ - @spec cleanup_subscriptions(Phoenix.LiveView.Socket.t()) :: :ok - def cleanup_subscriptions(socket) do - subscriptions = get_subscriptions(socket) + defp delete_subscription(socket, subscription) do + table = ensure_subscription_table() + scope = session_scope(socket) - Enum.each(subscriptions, fn subscription -> - unsubscribe_from_resource(subscription) - end) + for {^scope, existing} <- :ets.lookup(table, scope), + existing.id == subscription.id do + :ets.delete_object(table, {scope, existing}) + end :ok end - defp refresh_screen_bindings(screen, socket, user, params) do - try do - Integration.evaluate_bindings(screen, socket, user, params) - rescue - error -> {:error, error} + defp registry_subscriptions(scope) do + table = ensure_subscription_table() + + table + |> :ets.lookup(scope) + |> Enum.map(fn {^scope, subscription} -> subscription end) + end + + defp clear_scope(socket) do + table = ensure_subscription_table() + :ets.match_delete(table, {session_scope(socket), :_}) + end + + defp ensure_subscription_table do + case :ets.whereis(@subscription_table) do + :undefined -> + try do + :ets.new(@subscription_table, [:named_table, :public, :bag, read_concurrency: true]) + rescue + ArgumentError -> @subscription_table + end + + table -> + table end end + + defp merge_unique_subscriptions(subscriptions) do + subscriptions + |> Enum.reject(&is_nil/1) + |> Enum.uniq_by(fn + %{id: id} -> id + %{"id" => id} -> id + other -> inspect(other) + end) + end + + defp get_notification_resource(%{resource: %{__struct__: resource}}), do: resource + defp get_notification_resource(%{resource: resource}) when is_atom(resource), do: resource + defp get_notification_resource(_), do: nil + + defp binding_id(binding) do + Map.get(binding, :id) || Map.get(binding, "id") + end + + defp storage_key(binding) do + Map.get(binding, :binding_key) || Map.get(binding, "binding_key") || binding_id(binding) + end end diff --git a/lib/ash_ui/rendering/registry.ex b/lib/ash_ui/rendering/registry.ex index a930f72b..36d4070e 100644 --- a/lib/ash_ui/rendering/registry.ex +++ b/lib/ash_ui/rendering/registry.ex @@ -1,16 +1,16 @@ defmodule AshUI.Rendering.Registry do @moduledoc """ - Registry for tracking and managing available renderer packages. + Registry for tracking renderer package availability and adapter fallback state. - This module provides functionality to: - - Detect available renderer packages (live_ui, web_ui, desktop_ui) - - Register renderers at application startup - - Query available renderers - - Get renderer modules by type + The registry keeps two truths separate: + - whether the external renderer package is installed + - whether Ash UI can still render via its in-repo adapter fallback """ use GenServer + @renderer_types [:liveview, :html, :desktop] + @doc """ Starts the renderer registry. """ @@ -19,83 +19,89 @@ defmodule AshUI.Rendering.Registry do end @doc """ - Lists all available renderers. - - ## Returns - * `[%{type: atom(), module: module(), available: boolean()}]` - List of renderers + Lists all known renderers with their availability and fallback mode. """ - @spec list_renderers() :: [map()] - def list_renderers do - GenServer.call(__MODULE__, :list_renderers) + @spec list_renderers(keyword()) :: [map()] + def list_renderers(opts \\ []) do + GenServer.call(__MODULE__, {:list_renderers, opts}) end @doc """ - Gets the renderer module for a given renderer type. + Returns renderer status information for a given type. - ## Parameters - * `type` - Renderer type: `:liveview`, `:html`, or `:desktop` - - ## Returns - * `{:ok, module()}` - Renderer module found - * `{:error, :not_available}` - Renderer not available - * `{:error, :not_found}` - Renderer type not found + The returned map always reflects: + - `:available` - whether the external package is present + - `:renderable` - whether Ash UI can render with the current fallback policy + - `:mode` - `:external`, `:adapter_fallback`, or `:unavailable` """ - @spec get_renderer(atom()) :: {:ok, module()} | {:error, atom()} - def get_renderer(:liveview) do - GenServer.call(__MODULE__, {:get_renderer, :liveview}) - end + @spec renderer_info(atom(), keyword()) :: {:ok, map()} | {:error, atom()} + def renderer_info(type, opts \\ []) - def get_renderer(:html) do - GenServer.call(__MODULE__, {:get_renderer, :html}) + def renderer_info(type, opts) when type in @renderer_types do + GenServer.call(__MODULE__, {:renderer_info, type, opts}) end - def get_renderer(:desktop) do - GenServer.call(__MODULE__, {:get_renderer, :desktop}) - end + def renderer_info(_other, _opts), do: {:error, :not_found} + + @doc """ + Resolves the renderer to use for a given type. + + ## Options + * `:allow_adapter_fallback` - allow in-repo adapter fallback when the + external package is not installed. Defaults to config. + """ + @spec resolve_renderer(atom(), keyword()) :: {:ok, map()} | {:error, atom()} + def resolve_renderer(type, opts \\ []) - def get_renderer(_other) do - {:error, :not_found} + def resolve_renderer(type, opts) when type in @renderer_types do + GenServer.call(__MODULE__, {:resolve_renderer, type, opts}) end - @doc """ - Checks if a renderer type is available. + def resolve_renderer(_other, _opts), do: {:error, :not_found} - ## Parameters - * `type` - Renderer type: `:liveview`, `:html`, or `:desktop` + @doc """ + Gets the renderer module for a given renderer type. + """ + @spec get_renderer(atom()) :: {:ok, module()} | {:error, atom()} + def get_renderer(type) do + get_renderer(type, []) + end - ## Returns - * `true` - Renderer is available - * `false` - Renderer is not available + @doc """ + Gets the renderer module for a given type using the provided fallback policy. """ - @spec renderer_available?(atom()) :: boolean() - def renderer_available?(:liveview) do - case get_renderer(:liveview) do - {:ok, _module} -> true - _ -> false + @spec get_renderer(atom(), keyword()) :: {:ok, module()} | {:error, atom()} + def get_renderer(type, opts) do + case resolve_renderer(type, opts) do + {:ok, info} -> {:ok, info.module} + error -> error end end - def renderer_available?(:html) do - case get_renderer(:html) do - {:ok, _module} -> true + @doc """ + Checks whether the external renderer package is installed. + """ + @spec renderer_available?(atom()) :: boolean() + def renderer_available?(type) do + case renderer_info(type) do + {:ok, info} -> info.available _ -> false end end - def renderer_available?(:desktop) do - case get_renderer(:desktop) do - {:ok, _module} -> true + @doc """ + Checks whether Ash UI can render with the given renderer type. + """ + @spec renderer_renderable?(atom(), keyword()) :: boolean() + def renderer_renderable?(type, opts \\ []) do + case resolve_renderer(type, opts) do + {:ok, _info} -> true _ -> false end end - def renderer_available?(_other), do: false - @doc """ Refreshes the renderer registry by checking availability again. - - ## Returns - * `:ok` - Registry refreshed """ @spec refresh() :: :ok def refresh do @@ -104,30 +110,29 @@ defmodule AshUI.Rendering.Registry do @doc """ Gets the default renderer for the current environment. - - ## Returns - * `{:ok, type, module()}` - Default renderer type and module - * `{:error, :no_renderer}` - No renderer available """ - @spec default_renderer() :: {:ok, atom(), module()} | {:error, atom()} - def default_renderer do - configured = Application.get_env(:ash_ui, :rendering, []) - default = Keyword.get(configured, :default_renderer, :liveview) - - case get_renderer(default) do - {:ok, module} -> {:ok, default, module} - {:error, :not_available} -> find_fallback_renderer() - error -> error + @spec default_renderer(keyword()) :: {:ok, atom(), module()} | {:error, atom()} + def default_renderer(opts \\ []) do + configured = rendering_config() + + default = + Keyword.get(opts, :default_renderer, Keyword.get(configured, :default_renderer, :liveview)) + + case resolve_renderer(default, opts) do + {:ok, info} -> + {:ok, default, info.module} + + {:error, :not_available} -> + find_fallback_renderer(Keyword.put(opts, :exclude, default)) + + error -> + error end end - # Server Callbacks - @impl true @doc """ - GenServer callback to initialize the renderer registry. - - Detects all available renderer packages and stores their information. + Initializes the registry state with the current renderer availability snapshot. """ def init(_opts) do state = %{ @@ -140,39 +145,41 @@ defmodule AshUI.Rendering.Registry do @impl true @doc """ - GenServer callback for synchronous requests. - - Supports: - - `:list_renderers` - Returns list of all registered renderers - - `{:get_renderer, type}` - Returns renderer module for given type - - `{:renderer_available?, type}` - Checks if renderer is available - - `:default_renderer` - Returns the default renderer + Handles registry reads and refresh requests. """ - def handle_call(:list_renderers, _from, state) do + def handle_call({:list_renderers, opts}, _from, state) do renderers = state.renderers - |> Enum.map(fn {type, info} -> - %{ - type: type, - module: info.module, - available: info.available, - description: info.description - } - end) + |> Enum.map(fn {type, info} -> public_renderer_info(type, info, opts) end) {:reply, renderers, state} end @impl true - def handle_call({:get_renderer, type}, _from, state) do + def handle_call({:renderer_info, type, opts}, _from, state) do case Map.get(state.renderers, type) do - %{module: module} -> - # Always return the module - it will be either the external renderer - # or the AshUI adapter with fallback implementation - {:reply, {:ok, module}, state} + nil -> + {:reply, {:error, :not_found}, state} + info -> + {:reply, {:ok, public_renderer_info(type, info, opts)}, state} + end + end + + @impl true + def handle_call({:resolve_renderer, type, opts}, _from, state) do + case Map.get(state.renderers, type) do nil -> {:reply, {:error, :not_found}, state} + + info -> + renderer_info = public_renderer_info(type, info, opts) + + if renderer_info.renderable do + {:reply, {:ok, renderer_info}, state} + else + {:reply, {:error, :not_available}, state} + end end end @@ -186,96 +193,143 @@ defmodule AshUI.Rendering.Registry do {:reply, :ok, new_state} end - # Private Functions - - # Detect available renderer packages defp detect_renderers do %{ - liveview: detect_live_ui(), - html: detect_web_ui(), - desktop: detect_desktop_ui() + liveview: + detect_renderer( + LiveUI.Renderer, + AshUI.Rendering.LiveUIAdapter, + "Phoenix LiveView renderer (live_ui)" + ), + html: + detect_renderer( + WebUI.Renderer, + AshUI.Rendering.WebUIAdapter, + "Static HTML renderer (web_ui)" + ), + desktop: + detect_renderer( + DesktopUI.Renderer, + AshUI.Rendering.DesktopUIAdapter, + "Native desktop renderer (desktop_ui)" + ) } end - # Detect LiveUI renderer - defp detect_live_ui do + defp detect_renderer(external_module, adapter_module, description) do %{ - module: try_live_ui_module(), - available: Code.ensure_loaded?(LiveUI.Renderer), - description: "Phoenix LiveView renderer (live_ui)" + external_module: external_module, + adapter_module: adapter_module, + external_available: Code.ensure_loaded?(external_module), + adapter_available: Code.ensure_loaded?(adapter_module), + description: description } end - # Detect WebUI renderer - defp detect_web_ui do - %{ - module: try_web_ui_module(), - available: Code.ensure_loaded?(WebUI.Renderer), - description: "Static HTML renderer (web_ui)" - } - end + defp public_renderer_info(type, info, opts) do + allow_adapter_fallback = adapter_fallback_enabled?(opts) + + {module, mode, renderable} = + cond do + info.external_available -> + {info.external_module, :external, true} + + allow_adapter_fallback and info.adapter_available -> + {info.adapter_module, :adapter_fallback, true} + + true -> + {nil, :unavailable, false} + end - # Detect DesktopUI renderer - defp detect_desktop_ui do %{ - module: try_desktop_ui_module(), - available: Code.ensure_loaded?(DesktopUI.Renderer), - description: "Native desktop renderer (desktop_ui)" + type: type, + module: module, + external_module: info.external_module, + adapter_module: info.adapter_module, + available: info.external_available, + renderable: renderable, + mode: mode, + description: info.description } end - # Try to get LiveUI module, return placeholder if not available - defp try_live_ui_module do - if Code.ensure_loaded?(LiveUI.Renderer) do - LiveUI.Renderer + defp find_fallback_renderer(opts) do + configured = rendering_config() + + requested_fallback = + Keyword.get(opts, :fallback_renderer, Keyword.get(configured, :fallback_renderer)) + + exclude = excluded_types(opts) + + with {:ok, fallback} <- validate_requested_fallback(requested_fallback, exclude), + {:ok, info} <- resolve_renderer(fallback, fallback_opts(opts)) do + {:ok, fallback, info.module} else - AshUI.Rendering.LiveUIAdapter + {:error, :skip_requested_fallback} -> + find_first_renderable(exclude, fallback_opts(opts)) + + {:error, :not_available} -> + find_first_renderable(exclude, fallback_opts(opts)) + + {:error, :no_requested_fallback} -> + find_first_renderable(exclude, fallback_opts(opts)) + + error -> + error end end - # Try to get WebUI module, return placeholder if not available - defp try_web_ui_module do - if Code.ensure_loaded?(WebUI.Renderer) do - WebUI.Renderer - else - AshUI.Rendering.WebUIAdapter - end + defp find_first_renderable(exclude, opts) do + @renderer_types + |> Enum.reject(&(&1 in exclude)) + |> Enum.find_value({:error, :no_renderer}, fn type -> + case resolve_renderer(type, opts) do + {:ok, info} -> {:ok, type, info.module} + _ -> false + end + end) end - # Try to get DesktopUI module, return placeholder if not available - defp try_desktop_ui_module do - if Code.ensure_loaded?(DesktopUI.Renderer) do - DesktopUI.Renderer + defp validate_requested_fallback(nil, _exclude), do: {:error, :no_requested_fallback} + + defp validate_requested_fallback(fallback, exclude) when fallback in @renderer_types do + if fallback in exclude do + {:error, :skip_requested_fallback} else - AshUI.Rendering.DesktopUIAdapter + {:ok, fallback} end end - # Find fallback renderer when default is unavailable - defp find_fallback_renderer do - configured = Application.get_env(:ash_ui, :rendering, []) - fallback = Keyword.get(configured, :fallback_renderer) + defp validate_requested_fallback(_fallback, _exclude), do: {:error, :not_found} - if fallback do - case get_renderer(fallback) do - {:ok, module} -> {:ok, fallback, module} - error -> error - end - else - # Try to find any available renderer - cond do - renderer_available?(:liveview) -> - {:ok, :liveview, elem(get_renderer(:liveview), 1)} + defp excluded_types(opts) do + opts + |> Keyword.get(:exclude, []) + |> List.wrap() + end - renderer_available?(:html) -> - {:ok, :html, elem(get_renderer(:html), 1)} + defp fallback_opts(opts) do + allow_adapter_fallback = + Keyword.get( + opts, + :fallback_allow_adapter_fallback, + Keyword.get(opts, :allow_adapter_fallback, adapter_fallback_enabled?([])) + ) - renderer_available?(:desktop) -> - {:ok, :desktop, elem(get_renderer(:desktop), 1)} + Keyword.put(opts, :allow_adapter_fallback, allow_adapter_fallback) + end - true -> - {:error, :no_renderer} - end - end + defp adapter_fallback_enabled?(opts) do + configured = rendering_config() + + Keyword.get( + opts, + :allow_adapter_fallback, + Keyword.get(configured, :allow_adapter_fallback, true) + ) + end + + defp rendering_config do + Application.get_env(:ash_ui, :rendering, []) end end diff --git a/lib/ash_ui/rendering/selector.ex b/lib/ash_ui/rendering/selector.ex index bd18b352..ba29ed23 100644 --- a/lib/ash_ui/rendering/selector.ex +++ b/lib/ash_ui/rendering/selector.ex @@ -2,106 +2,107 @@ defmodule AshUI.Rendering.Selector do @moduledoc """ Runtime renderer selection based on request context and configuration. - This module provides automatic renderer selection based on: - - Request type (LiveView request → live_ui, HTTP request → web_ui) - - Explicit renderer override - - Fallback configuration - - Per-environment configuration - - ## Examples - - # Auto-select renderer based on request - {:ok, renderer} = Selector.select_for_request(conn) - - # Override renderer explicitly - {:ok, renderer} = Selector.select_for_request(conn, renderer: :html) - - # Select with fallback - {:ok, renderer, from_cache} = Selector.select_with_fallback(conn) + This selector understands the difference between: + - a real external renderer package being installed + - Ash UI using its in-repo adapter fallback for that renderer type """ + require Logger + alias AshUI.Rendering.Registry + alias AshUI.Telemetry - @doc """ - Selects appropriate renderer based on request context. + @renderer_types [:liveview, :html, :desktop] - ## Parameters - * `conn` - Phoenix connection or request context - * `opts` - Options + @doc """ + Selects an appropriate renderer based on request context. ## Options - * `:renderer` - Explicit renderer override (:liveview, :html, :desktop) - * `:ignore_headers` - Ignore X-Renderer header (default: false) - - ## Returns - * `{:ok, renderer_type, module}` - Selected renderer and module - * `{:error, reason}` - Selection failed + * `:renderer` - explicit renderer override + * `:ignore_headers` - ignore `x-renderer` + * `:allow_adapter_fallback` - allow adapter fallback for the selected type """ @spec select_for_request(Plug.Conn.t() | map(), keyword()) :: - {:ok, atom(), module()} | {:error, term()} + {:ok, atom(), module()} | {:error, term()} def select_for_request(conn_or_map, opts \\ []) do - cond do - # Explicit renderer override takes precedence - Keyword.has_key?(opts, :renderer) -> - renderer = Keyword.get(opts, :renderer) - get_renderer_with_validation(renderer) - - # Check for X-Renderer header (if not ignored) - not Keyword.get(opts, :ignore_headers, false) -> - case get_renderer_from_header(conn_or_map) do - {:ok, renderer} -> get_renderer_with_validation(renderer) - _error -> select_from_context(conn_or_map) - end - - # Auto-detect from context - true -> - select_from_context(conn_or_map) + with {:ok, renderer_type} <- select_renderer_type(conn_or_map, opts) do + get_renderer_with_validation(renderer_type, opts) end end @doc """ - Selects renderer with fallback support. - - ## Parameters - * `conn` - Phoenix connection or request context - * `opts` - Options + Selects a renderer with fallback support. - ## Returns - * `{:ok, renderer_type, module, from_cache}` - Renderer and cache status - * `{:error, reason}` - All renderers unavailable + The fourth tuple value indicates whether a fallback path was used. That is + true when either: + - an adapter fallback handled the selected renderer type + - selection switched to a different fallback renderer type """ @spec select_with_fallback(Plug.Conn.t() | map(), keyword()) :: - {:ok, atom(), module(), boolean()} | {:error, term()} + {:ok, atom(), module(), boolean()} | {:error, term()} def select_with_fallback(conn_or_map, opts \\ []) do - case select_for_request(conn_or_map, opts) do - {:ok, renderer_type, module} -> - {:ok, renderer_type, module, false} - - {:error, _reason} -> - # Try fallback renderer - case get_fallback_renderer() do - {:ok, renderer_type, module} -> - {:ok, renderer_type, module, true} - - error -> - error + with {:ok, requested_type} <- select_renderer_type(conn_or_map, opts) do + case Registry.resolve_renderer(requested_type, opts) do + {:ok, info} -> + fallback_used = info.mode == :adapter_fallback + + maybe_record_fallback( + requested_type, + requested_type, + info, + :adapter_fallback, + fallback_used + ) + + {:ok, requested_type, info.module, fallback_used} + + {:error, :not_available} -> + case resolve_fallback_renderer(Keyword.put(opts, :exclude, requested_type)) do + {:ok, fallback_type, fallback_info} -> + record_fallback(requested_type, fallback_type, fallback_info, :alternative_renderer) + {:ok, fallback_type, fallback_info.module, true} + + {:error, :no_fallback} -> + {:error, {:renderer_not_available, requested_type}} + + error -> + error + end + + {:error, :not_found} -> + {:error, {:renderer_not_found, requested_type}} + end + else + {:error, {:unknown_renderer, _unknown} = reason} -> + context_opts = Keyword.put(opts, :ignore_headers, true) + + case select_for_request(conn_or_map, context_opts) do + {:ok, fallback_type, module} -> + {:ok, fallback_info} = Registry.renderer_info(fallback_type, context_opts) + record_fallback(:unknown, fallback_type, fallback_info, reason) + {:ok, fallback_type, module, true} + + {:error, _context_reason} -> + case resolve_fallback_renderer(opts) do + {:ok, fallback_type, fallback_info} -> + record_fallback(:unknown, fallback_type, fallback_info, reason) + {:ok, fallback_type, fallback_info.module, true} + + _ -> + {:error, reason} + end end + + error -> + error end end @doc """ - Detects if request is a LiveView request. - - ## Parameters - * `conn_or_map` - Phoenix connection or request context - - ## Returns - * `true` - Request is LiveView - * `false` - Request is not LiveView + Detects if a request is a LiveView request. """ @spec liveview_request?(Plug.Conn.t() | map()) :: boolean() def liveview_request?(conn_or_map) do - # Check for LiveView indicators has_live_header = has_header?(conn_or_map, "accepts", ["text/vnd.phoenix.live-view"]) has_live_param = has_param?(conn_or_map, "_format", ["live", "liveview"]) has_live_session = has_session_key?(conn_or_map, "__phoenix_flash__") @@ -110,72 +111,120 @@ defmodule AshUI.Rendering.Selector do end @doc """ - Detects if request is a standard HTTP request. - - ## Parameters - * `conn_or_map` - Phoenix connection or request context - - ## Returns - * `true` - Request is standard HTTP - * `false` - Request is not standard HTTP + Detects if a request is a standard HTTP request. """ @spec http_request?(Plug.Conn.t() | map()) :: boolean() def http_request?(conn_or_map) do - # If it's not explicitly LiveView and has HTML accept, treat as HTTP not liveview_request?(conn_or_map) and has_header?(conn_or_map, "accept", ["text/html", "application/xhtml+xml"]) end @doc """ - Gets the fallback renderer from configuration. + Gets the fallback renderer from options or configuration. + """ + @spec get_fallback_renderer(keyword()) :: {:ok, atom(), module()} | {:error, atom()} + def get_fallback_renderer(opts \\ []) do + case resolve_fallback_renderer(opts) do + {:ok, renderer_type, info} -> {:ok, renderer_type, info.module} + error -> error + end + end - ## Returns - * `{:ok, renderer_type, module}` - Fallback renderer - * `{:error, :no_fallback}` - No fallback configured + @doc """ + Gets renderer for a specific environment. """ - @spec get_fallback_renderer() :: {:ok, atom(), module()} | {:error, atom()} - def get_fallback_renderer do + @spec select_for_environment(atom(), keyword()) :: {:ok, atom(), module()} | {:error, term()} + def select_for_environment(env, opts \\ []) + + def select_for_environment(env, opts) when env in [:dev, :test, :prod] do configured = Application.get_env(:ash_ui, :rendering, []) - fallback = Keyword.get(configured, :fallback_renderer) + env_renderers = Keyword.get(configured, :env_renderers, %{}) - if fallback do - get_renderer_with_validation(fallback) - else - # Auto-select fallback - cond do - Registry.renderer_available?(:html) -> - get_renderer_with_validation(:html) + case Map.get(env_renderers, env) do + nil -> + default = Keyword.get(configured, :default_renderer, :liveview) + get_renderer_with_validation(default, opts) - Registry.renderer_available?(:liveview) -> - get_renderer_with_validation(:liveview) + renderer_type -> + get_renderer_with_validation(renderer_type, opts) + end + end - Registry.renderer_available?(:desktop) -> - get_renderer_with_validation(:desktop) + def select_for_environment(_env, _opts) do + {:error, :invalid_environment} + end - true -> - {:error, :no_fallback} - end + defp select_renderer_type(conn_or_map, opts) do + cond do + Keyword.has_key?(opts, :renderer) -> + {:ok, Keyword.fetch!(opts, :renderer)} + + not Keyword.get(opts, :ignore_headers, false) -> + case get_renderer_from_header(conn_or_map) do + {:ok, renderer} -> {:ok, renderer} + {:error, :no_header} -> select_from_context(conn_or_map) + error -> error + end + + true -> + select_from_context(conn_or_map) end end - # Private Functions - defp select_from_context(conn_or_map) do cond do liveview_request?(conn_or_map) -> - get_renderer_with_validation(:liveview) + {:ok, :liveview} http_request?(conn_or_map) -> - get_renderer_with_validation(:html) + {:ok, :html} - # Default to configured default renderer true -> configured = Application.get_env(:ash_ui, :rendering, []) - default = Keyword.get(configured, :default_renderer, :liveview) - get_renderer_with_validation(default) + {:ok, Keyword.get(configured, :default_renderer, :liveview)} end end + defp resolve_fallback_renderer(opts) do + configured = Application.get_env(:ash_ui, :rendering, []) + + requested_fallback = + Keyword.get(opts, :fallback_renderer, Keyword.get(configured, :fallback_renderer)) + + exclude = excluded_types(opts) + + with {:ok, fallback} <- validate_requested_fallback(requested_fallback, exclude), + {:ok, info} <- Registry.resolve_renderer(fallback, fallback_opts(opts)) do + {:ok, fallback, info} + else + {:error, :skip_requested_fallback} -> + find_first_fallback(exclude, fallback_opts(opts)) + + {:error, :not_available} -> + find_first_fallback(exclude, fallback_opts(opts)) + + {:error, :no_requested_fallback} -> + find_first_fallback(exclude, fallback_opts(opts)) + + {:error, :not_found} -> + {:error, :no_fallback} + + error -> + error + end + end + + defp find_first_fallback(exclude, opts) do + @renderer_types + |> Enum.reject(&(&1 in exclude)) + |> Enum.find_value({:error, :no_fallback}, fn type -> + case Registry.resolve_renderer(type, opts) do + {:ok, info} -> {:ok, type, info} + _ -> false + end + end) + end + defp get_renderer_from_header(conn_or_map) do case get_request_header(conn_or_map, "x-renderer") do nil -> {:error, :no_header} @@ -189,10 +238,10 @@ defmodule AshUI.Rendering.Selector do end end - defp get_renderer_with_validation(renderer_type) do - case Registry.get_renderer(renderer_type) do - {:ok, module} -> - {:ok, renderer_type, module} + defp get_renderer_with_validation(renderer_type, opts) do + case Registry.resolve_renderer(renderer_type, opts) do + {:ok, info} -> + {:ok, renderer_type, info.module} {:error, :not_available} -> {:error, {:renderer_not_available, renderer_type}} @@ -206,12 +255,12 @@ defmodule AshUI.Rendering.Selector do header_value = get_request_header(conn_or_map, header_name) if is_binary(header_value) do - if length(values) > 0 do + if values == [] do + true + else Enum.any?(values, fn value -> String.contains?(String.downcase(header_value), value) end) - else - true end else false @@ -222,10 +271,10 @@ defmodule AshUI.Rendering.Selector do param_value = get_request_param(conn_or_map, param_name) if param_value do - if length(values) > 0 do - param_value in values - else + if values == [] do true + else + param_value in values end else false @@ -237,32 +286,23 @@ defmodule AshUI.Rendering.Selector do is_map(session) and Map.has_key?(session, key) end - # Generic request header extraction defp get_request_header(%Plug.Conn{} = conn, header_name) do - # Try to get from various locations in Plug.Conn conn |> Plug.Conn.get_req_header(header_name) |> case do - [value | _] -> value - [] -> nil + [value | _] when is_binary(value) and value != "" -> value + _ -> nil end end defp get_request_header(map, header_name) when is_map(map) do - # Try to get from request headers map headers = Map.get(map, :headers) || Map.get(map, "headers") || %{} - # Try both string and atom keys Map.get(headers, header_name) || - try do - Map.get(headers, String.to_atom(header_name)) - rescue - _ -> nil - end || + safe_get_atom_key(headers, header_name) || Map.get(headers, "http-#{String.downcase(header_name)}") end - # Generic request param extraction defp get_request_param(%Plug.Conn{} = conn, param_name) do params = conn.params || %{} Map.get(params, param_name) @@ -273,7 +313,6 @@ defmodule AshUI.Rendering.Selector do Map.get(params, param_name) end - # Generic session extraction defp get_request_session(%Plug.Conn{} = conn) do Map.get(conn.assigns, :session) || %{} end @@ -282,33 +321,68 @@ defmodule AshUI.Rendering.Selector do Map.get(map, :session) || Map.get(map, "session") || %{} end - @doc """ - Gets renderer for specific environment. - - ## Parameters - * `env` - Environment atom (:dev, :test, :prod) + defp safe_get_atom_key(map, key) do + Map.get(map, String.to_existing_atom(key)) + rescue + ArgumentError -> nil + end - ## Returns - * `{:ok, renderer_type, module}` - Environment-specific renderer - * `{:error, reason}` - Selection failed - """ - @spec select_for_environment(atom()) :: {:ok, atom(), module()} | {:error, term()} - def select_for_environment(env) when env in [:dev, :test, :prod] do - configured = Application.get_env(:ash_ui, :rendering, []) - env_renderers = Keyword.get(configured, :env_renderers, %{}) + defp excluded_types(opts) do + opts + |> Keyword.get(:exclude, []) + |> List.wrap() + end - case Map.get(env_renderers, env) do - nil -> - # Fall back to default renderer - default = Keyword.get(configured, :default_renderer, :liveview) - get_renderer_with_validation(default) + defp validate_requested_fallback(nil, _exclude), do: {:error, :no_requested_fallback} - renderer_type -> - get_renderer_with_validation(renderer_type) + defp validate_requested_fallback(fallback, exclude) when fallback in @renderer_types do + if fallback in exclude do + {:error, :skip_requested_fallback} + else + {:ok, fallback} end end - def select_for_environment(_env) do - {:error, :invalid_environment} + defp validate_requested_fallback(_fallback, _exclude), do: {:error, :not_found} + + defp fallback_opts(opts) do + allow_adapter_fallback = + Keyword.get( + opts, + :fallback_allow_adapter_fallback, + Keyword.get(opts, :allow_adapter_fallback, adapter_fallback_enabled?()) + ) + + Keyword.put(opts, :allow_adapter_fallback, allow_adapter_fallback) + end + + defp adapter_fallback_enabled? do + Application.get_env(:ash_ui, :rendering, []) + |> Keyword.get(:allow_adapter_fallback, true) + end + + defp maybe_record_fallback(_requested, _selected, _info, _reason, false), do: :ok + + defp maybe_record_fallback(requested, selected, info, reason, true) do + record_fallback(requested, selected, info, reason) + end + + defp record_fallback(requested, selected, info, reason) do + metadata = %{ + renderer: :fallback, + status: :ok, + requested_renderer: requested, + selected_renderer: selected, + resolved_mode: info.mode, + fallback_reason: reason + } + + Logger.warning( + "Renderer fallback engaged: requested=#{inspect(requested)} selected=#{inspect(selected)} " <> + "mode=#{inspect(info.mode)} reason=#{inspect(reason)}" + ) + + Telemetry.execute([:ash_ui, :render, :fallback], %{count: 1}, metadata) + :ok end end diff --git a/lib/ash_ui/resources/binding.ex b/lib/ash_ui/resources/binding.ex index 2bde6f76..ff9e8be8 100644 --- a/lib/ash_ui/resources/binding.ex +++ b/lib/ash_ui/resources/binding.ex @@ -7,6 +7,7 @@ defmodule AshUI.Resources.Binding do use Ash.Resource, domain: AshUI.Domain, + authorizers: [Ash.Policy.Authorizer], data_layer: AshPostgres.DataLayer postgres do @@ -58,11 +59,21 @@ defmodule AshUI.Resources.Binding do end end - # Note: Policy DSL requires Ash.Policy.Authorizer extension - # This will be added when authorization policies are fully implemented - # policies do - # policy action(:read) do - # authorize_if expr(active == true) - # end - # end + policies do + bypass actor_absent() do + authorize_if always() + end + + bypass actor_attribute_equals(:role, :admin) do + authorize_if always() + end + + policy action_type(:read) do + authorize_if {AshUI.Authorization.Checks.BindingAccess, mode: :read} + end + + policy action([:create, :update, :destroy]) do + authorize_if {AshUI.Authorization.Checks.BindingAccess, mode: :manage} + end + end end diff --git a/lib/ash_ui/resources/element.ex b/lib/ash_ui/resources/element.ex index db10dfc5..46eb3e2f 100644 --- a/lib/ash_ui/resources/element.ex +++ b/lib/ash_ui/resources/element.ex @@ -7,6 +7,7 @@ defmodule AshUI.Resources.Element do use Ash.Resource, domain: AshUI.Domain, + authorizers: [Ash.Policy.Authorizer], data_layer: AshPostgres.DataLayer postgres do @@ -53,11 +54,21 @@ defmodule AshUI.Resources.Element do end end - # Note: Policy DSL requires Ash.Policy.Authorizer extension - # This will be added when authorization policies are fully implemented - # policies do - # policy action(:read) do - # authorize_if expr(active == true) - # end - # end + policies do + bypass actor_absent() do + authorize_if always() + end + + bypass actor_attribute_equals(:role, :admin) do + authorize_if always() + end + + policy action_type(:read) do + authorize_if {AshUI.Authorization.Checks.ElementAccess, mode: :read} + end + + policy action([:create, :update, :destroy]) do + authorize_if {AshUI.Authorization.Checks.ElementAccess, mode: :manage} + end + end end diff --git a/lib/ash_ui/resources/screen.ex b/lib/ash_ui/resources/screen.ex index e13e1347..8ee25f06 100644 --- a/lib/ash_ui/resources/screen.ex +++ b/lib/ash_ui/resources/screen.ex @@ -5,6 +5,7 @@ defmodule AshUI.Resources.Screen do use Ash.Resource, domain: AshUI.Domain, + authorizers: [Ash.Policy.Authorizer], data_layer: AshPostgres.DataLayer postgres do @@ -42,6 +43,10 @@ defmodule AshUI.Resources.Screen do actions do defaults [:read] + read :mount do + get? true + end + create :create do primary? true accept [:name, :unified_dsl, :layout, :route, :metadata, :active, :version] @@ -59,11 +64,25 @@ defmodule AshUI.Resources.Screen do end end - # Note: Policy DSL requires Ash.Policy.Authorizer extension - # This will be added when authorization policies are fully implemented - # policies do - # policy action(:read) do - # authorize_if expr(active == true) - # end - # end + policies do + bypass actor_absent() do + authorize_if always() + end + + bypass actor_attribute_equals(:role, :admin) do + authorize_if always() + end + + policy action([:read, :mount]) do + authorize_if {AshUI.Authorization.Checks.ScreenAccess, mode: :read} + end + + policy action(:create) do + authorize_if {AshUI.Authorization.Checks.ScreenAccess, mode: :manage} + end + + policy action([:update, :destroy]) do + authorize_if {AshUI.Authorization.Checks.ScreenAccess, mode: :manage} + end + end end diff --git a/lib/ash_ui/runtime/action_binding.ex b/lib/ash_ui/runtime/action_binding.ex index 7a0b7aae..eb9b1383 100644 --- a/lib/ash_ui/runtime/action_binding.ex +++ b/lib/ash_ui/runtime/action_binding.ex @@ -7,6 +7,7 @@ defmodule AshUI.Runtime.ActionBinding do """ alias AshUI.Resources.Binding + alias AshUI.Runtime.ResourceAccess @type context :: %{ user_id: String.t() | nil, @@ -47,12 +48,10 @@ defmodule AshUI.Runtime.ActionBinding do {:ok, action_result()} | {:error, term()} def execute_action(binding, event_data, context, opts \\ []) do source = Map.get(binding, :source) || Map.get(binding, "source") || %{} - resource = Map.get(source, "resource") - action_name = Map.get(source, "action") with {:ok, :authorized} <- check_authorization(binding, context), {:ok, params} <- prepare_params(binding, event_data, context), - {:ok, result} <- call_ash_action(resource, action_name, params, context, opts) do + {:ok, result} <- call_ash_action(source, params, context, opts) do {:ok, %{ status: :ok, @@ -60,9 +59,6 @@ defmodule AshUI.Runtime.ActionBinding do errors: nil }} else - {:error, :unauthorized} -> - {:error, :unauthorized} - {:error, reason} -> {:error, %{ @@ -118,37 +114,25 @@ defmodule AshUI.Runtime.ActionBinding do @spec wire_handlers([Binding.t() | map()], map()) :: %{String.t() => function()} def wire_handlers(bindings, _socket) do action_bindings = - bindings - |> Enum.map(fn - {_id, binding} when is_map(binding) -> binding - binding when is_map(binding) -> binding - _other -> nil - end) - |> Enum.reject(&is_nil/1) - |> Enum.filter(fn b -> - type = b.binding_type || Map.get(b, "binding_type") + Enum.filter(bindings, fn b -> + type = Map.get(b, :binding_type) || Map.get(b, "binding_type") type in [:action, "action"] end) Enum.reduce(action_bindings, %{}, fn binding, acc -> - target = Map.get(binding, :target) || Map.get(binding, "target") + target = binding_target(binding) element_id = get_binding_element_id(binding) + fallback_id = Map.get(binding, :id) || Map.get(binding, "id") - handler_name = "ash_ui_action_#{target || element_id}" + handler_name = "ash_ui_action_#{target || element_id || fallback_id}" Map.put(acc, handler_name, event_handler(binding, element_id)) end) end # Check authorization before executing action - defp check_authorization(binding, context) do - _resource = get_in(binding, [:source, "resource"]) - _action = get_in(binding, [:source, "action"]) - user_id = Map.get(context, :user_id) - - # In production, this would call Ash.can?/3 - # For now, allow if user_id is present - if user_id do + defp check_authorization(_binding, context) do + if ResourceAccess.actor(context) do {:ok, :authorized} else {:error, :unauthorized} @@ -191,28 +175,12 @@ defmodule AshUI.Runtime.ActionBinding do defp get_param_value(_source, _event_data, _context), do: nil # Call Ash action - defp call_ash_action(resource, action_name, params, _context, _opts) do - # In production, this would call the actual Ash action - # Ash.run(Ash.Domain, resource, action_name, params) - mock_action_result(resource, action_name, params) - end - - defp mock_action_result(resource, action_name, params) do - # Mock action result - { - :ok, - %{ - "resource" => resource, - "action" => action_name, - "params" => params, - "result" => %{"id" => UUID.uuid4()} - } - } - end + defp call_ash_action(source, params, context, opts), + do: ResourceAccess.execute_action(source, params, context, opts) # Handle successful action defp handle_action_success(socket, binding, result) do - target = Map.get(binding, :target) || Map.get(binding, "target") + target = binding_target(binding) ash_ui = Map.get(socket.assigns, :ash_ui, %{}) actions = Map.get(ash_ui, :actions, %{}) action_state = Map.get(actions, target, %{}) @@ -239,7 +207,7 @@ defmodule AshUI.Runtime.ActionBinding do # Handle action error defp handle_action_error(socket, binding) do - target = Map.get(binding, :target) || Map.get(binding, "target") + target = binding_target(binding) ash_ui = Map.get(socket.assigns, :ash_ui, %{}) actions = Map.get(ash_ui, :actions, %{}) action_state = Map.get(actions, target, %{}) @@ -258,11 +226,14 @@ defmodule AshUI.Runtime.ActionBinding do # Build context from socket defp build_context(socket) do - user_id = get_in(socket.assigns, [:current_user_id]) + user = Map.get(socket.assigns, :current_user) + user_id = get_in(socket.assigns, [:current_user_id]) || Map.get(user || %{}, :id) params = Map.get(socket.assigns, :params, %{}) %{ user_id: user_id, + user: user, + authorize?: true, params: params, assigns: socket.assigns } @@ -277,6 +248,10 @@ defmodule AshUI.Runtime.ActionBinding do Map.get(binding, :element_id) || Map.get(binding, "element_id") end + defp binding_target(binding) do + Map.get(binding, :target) || Map.get(binding, "target") + end + defp put_flash(socket, kind, message) do # In production, this would use Phoenix.LiveView.put_flash/3 # For now, store in assigns diff --git a/lib/ash_ui/runtime/bidirectional_binding.ex b/lib/ash_ui/runtime/bidirectional_binding.ex index 3420954c..d660a5b5 100644 --- a/lib/ash_ui/runtime/bidirectional_binding.ex +++ b/lib/ash_ui/runtime/bidirectional_binding.ex @@ -7,6 +7,7 @@ defmodule AshUI.Runtime.BidirectionalBinding do """ alias AshUI.Runtime.BindingEvaluator + alias AshUI.Runtime.ResourceAccess alias AshUI.Resources.Binding alias AshUI.Telemetry @@ -90,8 +91,6 @@ defmodule AshUI.Runtime.BidirectionalBinding do def subscribe_binding(binding, socket, _context) do # Track subscription for this binding subscription_id = subscription_id(binding) - source = Map.get(binding, :source) || Map.get(binding, "source") || %{} - target = Map.get(binding, :target) || Map.get(binding, "target") # In production, this would subscribe to Ash.Notifier # For now, track in socket assigns @@ -100,18 +99,12 @@ defmodule AshUI.Runtime.BidirectionalBinding do updated_subscriptions = Map.put(subscriptions, subscription_id, %{ binding_id: get_binding_id(binding), - source: source, - target: target, + source: binding.source, + target: binding.target, subscribed_at: System.system_time(:millisecond) }) - updated_assigns = - put_in(socket.assigns, [ - Access.key(:ash_ui, %{}), - Access.key(:subscriptions, %{}) - ], updated_subscriptions) - - updated_socket = %{socket | assigns: updated_assigns} + updated_socket = %{socket | assigns: put_in(socket.assigns, [:ash_ui, :subscriptions], updated_subscriptions)} {:ok, updated_socket} end @@ -232,21 +225,7 @@ defmodule AshUI.Runtime.BidirectionalBinding do # Update Ash resource with new value defp update_resource(binding, value, context) do source = Map.get(binding, :source) || Map.get(binding, "source") || %{} - resource = Map.get(source, "resource") - field = Map.get(source, "field") - id = get_resource_id(source, context) - - # In production, this would call Ash.Domain.update/3 - # For now, return a mock result - mock_update_result(resource, id, field, value) - end - - defp get_resource_id(source, context) do - Map.get(source, "id") || Map.get(context, :resource_id) - end - - defp mock_update_result(_resource, _id, _field, value) do - {:ok, %{status: :ok, value: value}} + ResourceAccess.write_field(source, value, context) end # Helper functions for socket management @@ -261,10 +240,13 @@ defmodule AshUI.Runtime.BidirectionalBinding do "updated_at" => System.system_time(:millisecond) }) - %{ - socket - | assigns: Map.put(socket.assigns, :ash_ui, Map.put(ash_ui, :bindings, updated_bindings)) - } + socket = + %{ + socket + | assigns: Map.put(socket.assigns, :ash_ui, Map.put(ash_ui, :bindings, updated_bindings)) + } + + update_binding_state(socket, binding, %{value: value, error: nil}) end defp get_binding_value(socket, binding) do @@ -282,12 +264,22 @@ defmodule AshUI.Runtime.BidirectionalBinding do ash_ui = Map.get(socket.assigns, :ash_ui, %{}) bindings = Map.get(ash_ui, :bindings, %{}) binding_state = Map.get(bindings, target, %{}) - updated_bindings = Map.put(bindings, target, Map.put(binding_state, "error", error)) - - %{ - socket - | assigns: Map.put(socket.assigns, :ash_ui, Map.put(ash_ui, :bindings, updated_bindings)) - } + updated_bindings = + Map.put( + bindings, + target, + binding_state + |> Map.put("error", error) + |> Map.put("updated_at", System.system_time(:millisecond)) + ) + + socket = + %{ + socket + | assigns: Map.put(socket.assigns, :ash_ui, Map.put(ash_ui, :bindings, updated_bindings)) + } + + update_binding_state(socket, binding, %{error: error}) end defp get_binding_id(%Binding{id: id}), do: id @@ -297,6 +289,46 @@ defmodule AshUI.Runtime.BidirectionalBinding do "#{get_binding_id(binding)}_#{System.system_time(:millisecond)}" end + defp update_binding_state(socket, binding, attrs) do + binding_id = get_binding_id(binding) + bindings = Map.get(socket.assigns, :ash_ui_bindings, %{}) + atom_binding_id = maybe_to_existing_atom(binding_id) + + existing_binding = + Map.get(bindings, binding_id) || + (atom_binding_id && Map.get(bindings, atom_binding_id)) + + if is_map(existing_binding) do + updated_binding = + existing_binding + |> Map.merge(attrs) + |> Map.put(:updated_at, System.system_time(:millisecond)) + + updated_bindings = + bindings + |> Map.put(binding_id, updated_binding) + |> maybe_put(atom_binding_id, updated_binding) + + %{socket | assigns: Map.put(socket.assigns, :ash_ui_bindings, updated_bindings)} + else + socket + end + end + + defp maybe_put(map, nil, _value), do: map + defp maybe_put(map, key, value), do: Map.put(map, key, value) + + defp maybe_to_existing_atom(value) when is_binary(value) do + try do + String.to_existing_atom(value) + rescue + ArgumentError -> nil + end + end + + defp maybe_to_existing_atom(value) when is_atom(value), do: value + defp maybe_to_existing_atom(_value), do: nil + defp emit_binding_update_telemetry(binding, context, started_at, result) do duration = System.monotonic_time() - started_at diff --git a/lib/ash_ui/runtime/binding_evaluator.ex b/lib/ash_ui/runtime/binding_evaluator.ex index 037fe95f..b6781f03 100644 --- a/lib/ash_ui/runtime/binding_evaluator.ex +++ b/lib/ash_ui/runtime/binding_evaluator.ex @@ -7,6 +7,7 @@ defmodule AshUI.Runtime.BindingEvaluator do """ alias AshUI.Resources.Binding + alias AshUI.Runtime.ResourceAccess alias AshUI.Telemetry @type context :: %{ @@ -82,17 +83,16 @@ defmodule AshUI.Runtime.BindingEvaluator do end # Resolve field or relationship from resource - defp resolve_field_or_relationship(%{"resource" => resource} = source, context, opts) do + defp resolve_field_or_relationship(source, context, opts) do field = Map.get(source, "field") relationship = Map.get(source, "relationship") - id = Map.get(source, "id") cond do field -> - resolve_field(resource, field, id, context, opts) + resolve_field(source, field, context, opts) relationship -> - resolve_relationship(resource, relationship, context, opts) + resolve_relationship(source, relationship, context, opts) true -> {:error, {:missing_field_or_relationship, source}} @@ -100,35 +100,11 @@ defmodule AshUI.Runtime.BindingEvaluator do end # Resolve a single field from a resource - defp resolve_field(resource_name, field, id, context, _opts) do - # Build Ash query to read the resource - # In production, this would use the actual Ash domain and resources - # For now, return a placeholder - {:ok, resource} = load_resource(resource_name, id, context) - value = get_field(resource, field) - {:ok, value} - end - - # Resolve a relationship (e.g., user.profile.name) - defp resolve_relationship(resource_name, relationship, context, _opts) do - parts = String.split(relationship, ".") - - {:ok, resource} = load_resource(resource_name, nil, context) - navigate_relationship(resource, parts, context) - end - - # Navigate through nested relationships - defp navigate_relationship(nil, _parts, _context), do: {:ok, nil} + defp resolve_field(source, field, context, _opts), + do: ResourceAccess.read_field(source, field, context) - defp navigate_relationship(resource, [part | rest], context) do - value = get_field(resource, part) - - if rest == [] do - {:ok, value} - else - navigate_relationship(value, rest, context) - end - end + defp resolve_relationship(source, relationship, context, _opts), + do: ResourceAccess.read_relationship(source, relationship, context) # Resolve an action source defp resolve_action(source, action_name, _context, _opts) do @@ -144,43 +120,9 @@ defmodule AshUI.Runtime.BindingEvaluator do }} end - # Load a resource by name and ID - defp load_resource(_resource_name, _id, _context) do - # Placeholder: In production, this would call Ash.Domain.get/3 - # For now, return mock data - {:ok, - %{ - "id" => "mock-id", - "name" => "Mock Resource", - "type" => "mock" - }} - end - - # Get a field from a resource (map or struct) - defp get_field(resource, field) when is_map(resource) do - key = String.to_existing_atom(field) - - case Map.get(resource, key) do - nil -> Map.get(resource, field) - value -> value - end - rescue - ArgumentError -> - Map.get(resource, field) - end - - defp get_field(_resource, _field), do: nil - # Apply transformations to the resolved value defp apply_transformations(value, transform, _context) do - transforms = - case transform do - nil -> [] - %{} = map when map_size(map) == 0 -> [] - %{} = map -> [map] - list when is_list(list) -> list - _ -> [] - end + transforms = List.wrap(transform) transformed = Enum.reduce(transforms, value, fn transform, acc -> @@ -238,6 +180,9 @@ defmodule AshUI.Runtime.BindingEvaluator do # Format a value (placeholder implementation) defp format_value(value) when is_binary(value), do: value defp format_value(value) when is_number(value), do: to_string(value) + defp format_value(%DateTime{} = value), do: DateTime.to_iso8601(value) + defp format_value(%NaiveDateTime{} = value), do: NaiveDateTime.to_iso8601(value) + defp format_value(%Date{} = value), do: Date.to_iso8601(value) defp format_value(value), do: inspect(value) @doc """ diff --git a/lib/ash_ui/runtime/list_binding.ex b/lib/ash_ui/runtime/list_binding.ex index 25897ed1..fb5c938e 100644 --- a/lib/ash_ui/runtime/list_binding.ex +++ b/lib/ash_ui/runtime/list_binding.ex @@ -6,6 +6,8 @@ defmodule AshUI.Runtime.ListBinding do of Ash resources to UI elements like lists and tables. """ + alias AshUI.Runtime.ResourceAccess + @type context :: %{ user_id: String.t() | nil, params: map(), @@ -44,7 +46,7 @@ defmodule AshUI.Runtime.ListBinding do """ @spec load_collection(map(), context(), keyword()) :: {:ok, list_result()} | {:error, term()} def load_collection(binding, context, opts \\ []) do - source = binding_source(binding) + source = Map.get(binding, :source) || Map.get(binding, "source") || %{} resource = Map.get(source, "resource") relationship = Map.get(source, "relationship") @@ -84,7 +86,7 @@ defmodule AshUI.Runtime.ListBinding do @spec subscribe_collection(map(), map(), context()) :: {:ok, map()} def subscribe_collection(binding, socket, _context) do subscription_id = collection_subscription_id(binding) - source = binding_source(binding) + source = Map.get(binding, :source) || Map.get(binding, "source") || %{} # Track collection subscription subscriptions = get_in(socket.assigns, [:ash_ui, :list_subscriptions]) || %{} @@ -97,7 +99,7 @@ defmodule AshUI.Runtime.ListBinding do } updated_subscriptions = Map.put(subscriptions, subscription_id, subscription) - updated_socket = put_in(socket.assigns, [:ash_ui, :list_subscriptions], updated_subscriptions) + updated_socket = %{socket | assigns: put_in(socket.assigns, [:ash_ui, :list_subscriptions], updated_subscriptions)} {:ok, updated_socket} end @@ -143,7 +145,7 @@ defmodule AshUI.Runtime.ListBinding do """ @spec format_collection(list_result(), map(), context()) :: {:ok, [map()]} | {:error, term()} def format_collection(list_result, binding, context) do - transform = Map.get(binding, :transform) || Map.get(binding, "transform") || %{} + transform = binding.transform || %{} items = list_result.items formatted = @@ -156,149 +158,79 @@ defmodule AshUI.Runtime.ListBinding do # Private functions - defp load_resource_collection(resource, relationship, page, page_size, filters, _context) do - # In production, this would use Ash.Query to load the collection - # For now, return mock data - mock_load_collection(resource, relationship, page, page_size, filters) - end - - defp mock_load_collection(resource, relationship, page, page_size, _filters) do - total = 100 - start_index = (page - 1) * page_size + 1 - - # Generate mock collection data - items = - if start_index > total do - [] - else - end_index = min(start_index + page_size - 1, total) - - Enum.map(start_index..end_index, fn index -> - %{ - "id" => "#{resource}-#{relationship}-#{index}", - "type" => relationship, - "index" => index - } - end) - end - - {:ok, - %{ - "items" => items, - "total" => total, - "page" => page - }} + defp load_resource_collection(resource, relationship, page, page_size, filters, context) do + source = + %{} + |> Map.put("resource", resource) + |> maybe_put("relationship", relationship) + + ResourceAccess.read_collection( + source, + context, + page: page, + page_size: page_size, + filters: filters + ) end defp get_total_count(collection) do - Map.get(collection, "total", length(Map.get(collection, "items", []))) + Map.get(collection, :total) || Map.get(collection, "total") || length(extract_items(collection)) end defp extract_items(collection) do - Map.get(collection, "items", []) + Map.get(collection, :items) || Map.get(collection, "items", []) end defp handle_insert(binding, change_data, socket, _context) do # For insert, we may want to prepend to the list or refresh - target = Map.get(binding, :target) || Map.get(binding, "target") + target = binding.target || Map.get(binding, "target") # Store change for UI update - changes = - get_in(socket.assigns, [ - Access.key(:ash_ui, %{}), - Access.key(:list_changes, %{}), - Access.key(target, []) - ]) - + changes = get_in(socket.assigns, [:ash_ui, :list_changes, target]) || [] updated_changes = [{:insert, change_data} | changes] - updated_assigns = - put_in(socket.assigns, [ - Access.key(:ash_ui, %{}), - Access.key(:list_changes, %{}), - Access.key(target, []) - ], updated_changes) - - updated_socket = %{socket | assigns: updated_assigns} + updated_socket = + put_assign_path(socket, [:ash_ui, :list_changes, target], updated_changes) {:ok, updated_socket, true} end defp handle_update(binding, change_data, socket, _context) do # For update, find the item and update it - target = Map.get(binding, :target) || Map.get(binding, "target") - item_id = Map.get(change_data, "id") + target = binding.target || Map.get(binding, "target") + item_id = item_value(change_data, "id") # Update the item in the cached list - items = - get_in(socket.assigns, [ - Access.key(:ash_ui, %{}), - Access.key(:lists, %{}), - Access.key(target, %{}), - Access.key("items", []) - ]) + items = get_in(socket.assigns, [:ash_ui, :lists, target, "items"]) || [] updated_items = Enum.map(items, fn item -> - if Map.get(item, "id") == item_id do - Map.merge(item, change_data) + if item_value(item, "id") == item_id do + merge_item_change(item, change_data) else item end end) - updated_assigns = - put_in(socket.assigns, [ - Access.key(:ash_ui, %{}), - Access.key(:lists, %{}), - Access.key(target, %{}), - Access.key("items", []) - ], updated_items) - - updated_socket = %{socket | assigns: updated_assigns} + updated_socket = put_assign_path(socket, [:ash_ui, :lists, target, "items"], updated_items) {:ok, updated_socket, true} end defp handle_delete(binding, change_data, socket, _context) do # For delete, remove the item from the list - target = Map.get(binding, :target) || Map.get(binding, "target") - item_id = Map.get(change_data, "id") - - items = - get_in(socket.assigns, [ - Access.key(:ash_ui, %{}), - Access.key(:lists, %{}), - Access.key(target, %{}), - Access.key("items", []) - ]) - - updated_items = Enum.reject(items, fn item -> Map.get(item, "id") == item_id end) - current_total = - get_in(socket.assigns, [ - Access.key(:ash_ui, %{}), - Access.key(:lists, %{}), - Access.key(target, %{}), - Access.key("total", 0) - ]) - - updated_total = max(current_total - 1, 0) + target = binding.target || Map.get(binding, "target") + item_id = item_value(change_data, "id") - updated_assigns = - socket.assigns - |> put_in([ - Access.key(:ash_ui, %{}), - Access.key(:lists, %{}), - Access.key(target, %{}), - Access.key("items", []) - ], updated_items) - |> put_in([ - Access.key(:ash_ui, %{}), - Access.key(:lists, %{}), - Access.key(target, %{}), - Access.key("total", 0) - ], updated_total) - - updated_socket = %{socket | assigns: updated_assigns} + items = get_in(socket.assigns, [:ash_ui, :lists, target, "items"]) || [] + + updated_items = Enum.reject(items, fn item -> item_value(item, "id") == item_id end) + + updated_socket = put_assign_path(socket, [:ash_ui, :lists, target, "items"], updated_items) + + # Update total count + current_total = get_in(socket.assigns, [:ash_ui, :lists, target, "total"]) || 0 + updated_socket = + put_assign_path(updated_socket, [:ash_ui, :lists, target, "total"], max(current_total - 1, 0)) {:ok, updated_socket, true} end @@ -348,14 +280,71 @@ defmodule AshUI.Runtime.ListBinding do Map.get(binding, :id) || Map.get(binding, "id") end - defp binding_source(binding) do - Map.get(binding, :source) || Map.get(binding, "source") || %{} - end - defp collection_subscription_id(binding) do - source = binding_source(binding) - resource = Map.get(source, "resource") - relationship = Map.get(source, "relationship") + source = Map.get(binding, :source) || Map.get(binding, "source") || %{} + resource = Map.get(source, :resource) || Map.get(source, "resource") + relationship = Map.get(source, :relationship) || Map.get(source, "relationship") "list_#{resource}_#{relationship}" end + + defp maybe_put(map, _key, nil), do: map + defp maybe_put(map, key, value), do: Map.put(map, key, value) + + defp put_assign_path(socket, path, value) do + updated_assigns = + put_in( + socket.assigns, + Enum.map(path, fn + key when is_atom(key) -> Access.key(key, %{}) + key -> Access.key(key, %{}) + end), + value + ) + + %{socket | assigns: updated_assigns} + end + + defp item_value(item, key) when is_map(item) do + Map.get(item, key) || + Map.get(item, to_string(key)) || + map_get_existing_atom(item, key) + end + + defp item_value(_item, _key), do: nil + + defp map_get_existing_atom(map, key) do + atom_key = + try do + key |> to_string() |> String.to_existing_atom() + rescue + ArgumentError -> nil + end + + if atom_key, do: Map.get(map, atom_key) + end + + defp merge_item_change(%{__struct__: _} = item, change_data) do + Enum.reduce(change_data, item, fn {key, value}, acc -> + atom_key = + cond do + is_atom(key) -> + key + + true -> + try do + String.to_existing_atom(to_string(key)) + rescue + ArgumentError -> nil + end + end + + if atom_key && Map.has_key?(acc, atom_key) do + Map.put(acc, atom_key, value) + else + acc + end + end) + end + + defp merge_item_change(item, change_data) when is_map(item), do: Map.merge(item, change_data) end diff --git a/lib/ash_ui/runtime/resource_access.ex b/lib/ash_ui/runtime/resource_access.ex new file mode 100644 index 00000000..46c5538b --- /dev/null +++ b/lib/ash_ui/runtime/resource_access.ex @@ -0,0 +1,605 @@ +defmodule AshUI.Runtime.ResourceAccess do + @moduledoc """ + Real Ash-backed resource access helpers used by runtime bindings. + + This module resolves resource references, performs actor-aware reads and + writes, and normalizes action execution for the runtime binding layer. + """ + + require Ash.Query + + alias AshUI.Authorization.Policies + + @type context :: map() + @type resolved :: %{ + resource: module(), + domain: module(), + actor: term(), + tenant: term() | nil, + authorize?: boolean() + } + + @spec resolve(module() | String.t(), context()) :: {:ok, resolved()} | {:error, term()} + @doc """ + Resolves a resource reference to a concrete resource/domain pair for runtime use. + """ + def resolve(resource_ref, context) do + resource_ref + |> matching_resources(context) + |> unique_match(resource_ref, context) + end + + @spec read_field(map(), String.t(), context(), keyword()) :: {:ok, term()} | {:error, term()} + @doc """ + Reads a single field value from a resource record. + """ + def read_field(source, field, context, opts \\ []) do + with {:ok, resolved} <- resolve(source_resource(source), context), + {:ok, record} <- optional_record(source, context, Keyword.put(opts, :resolved, resolved)) do + {:ok, fetch_value(record, field)} + end + end + + @spec read_relationship(map(), String.t(), context(), keyword()) :: + {:ok, term()} | {:error, term()} + @doc """ + Reads a relationship path from a resource record. + """ + def read_relationship(source, relationship_path, context, opts \\ []) do + with {:ok, resolved} <- resolve(source_resource(source), context), + {:ok, record} <- optional_record(source, context, Keyword.put(opts, :resolved, resolved)) do + navigate( + record, + resolved.resource, + String.split(relationship_path, ".", trim: true), + resolved + ) + end + end + + @spec read_collection(map(), context(), keyword()) :: + {:ok, %{items: list(), total: non_neg_integer()}} | {:error, term()} + @doc """ + Reads a collection and applies in-memory filtering and pagination. + """ + def read_collection(source, context, opts \\ []) do + page = Keyword.get(opts, :page, 1) + page_size = Keyword.get(opts, :page_size, 20) + filters = Keyword.get(opts, :filters, %{}) + + with {:ok, resolved} <- resolve(source_resource(source), context), + {:ok, items} <- load_collection_items(source, resolved, context) do + filtered_items = apply_in_memory_filters(items, filters) + total = length(filtered_items) + + paged_items = + filtered_items |> Enum.drop(max(page - 1, 0) * page_size) |> Enum.take(page_size) + + {:ok, %{items: paged_items, total: total}} + end + end + + @spec write_field(map(), term(), context(), keyword()) :: {:ok, map()} | {:error, term()} + @doc """ + Writes a field through the resolved Ash resource update action. + """ + def write_field(source, value, context, opts \\ []) do + with {:ok, resolved} <- resolve(source_resource(source), context), + {:ok, record} <- required_record(source, context, Keyword.put(opts, :resolved, resolved)), + {:ok, attribute_name} <- resolve_attribute_name(resolved.resource, source_field(source)), + {:ok, updated} <- + Ash.update( + record, + %{attribute_name => value}, + update_opts(resolved, source, opts) + ) do + {:ok, %{status: :ok, record: updated, value: fetch_value(updated, attribute_name)}} + end + end + + @spec execute_action(map(), map(), context(), keyword()) :: {:ok, term()} | {:error, term()} + @doc """ + Executes an Ash action for the resolved resource and source binding. + """ + def execute_action(source, params, context, opts \\ []) do + with {:ok, resolved} <- resolve(source_resource(source), context), + {:ok, action} <- resolve_action(resolved.resource, source_action(source)) do + normalized_params = normalize_params(params, resolved.resource, action) + + case action.type do + :create -> + Ash.create(resolved.resource, normalized_params, create_opts(resolved, action, opts)) + + :update -> + with {:ok, record} <- + required_record(source, context, Keyword.put(opts, :resolved, resolved)) do + update_params = drop_primary_key(normalized_params, resolved.resource) + Ash.update(record, update_params, action_opts(resolved, action, opts)) + end + + :destroy -> + with {:ok, record} <- + required_record(source, context, Keyword.put(opts, :resolved, resolved)) do + Ash.destroy(record, action_opts(resolved, action, opts)) + end + + :action -> + resolved.resource + |> Ash.ActionInput.for_action(action.name, normalized_params) + |> Ash.run_action(action_opts(resolved, action, opts)) + + other -> + {:error, {:unsupported_action_type, other}} + end + end + end + + @doc """ + Extracts the effective actor from runtime context. + """ + def actor(context) do + cond do + Map.has_key?(context, :actor) and not is_nil(context.actor) -> + context.actor + + Map.has_key?(context, :user) and not is_nil(context.user) -> + context.user + + get_in(context, [:assigns, :current_user]) -> + get_in(context, [:assigns, :current_user]) + + Map.get(context, :user_id) -> + %{id: context.user_id} + + true -> + nil + end + end + + defp load_collection_items(source, resolved, context) do + parent_filters = build_filters(source, resolved.resource, context, filters: %{}) + + case source_relationship(source) do + nil -> + read_records(resolved, parent_filters) + + relationship_path -> + case record_id(source, resolved.resource, context) do + nil -> + with {:ok, records} <- read_records(resolved, parent_filters), + {:ok, values} <- + navigate( + records, + resolved.resource, + String.split(relationship_path, ".", trim: true), + resolved + ) do + {:ok, List.wrap(values) |> List.flatten()} + end + + _id -> + with {:ok, record} <- + required_record(source, context, resolved: resolved, filters: %{}), + {:ok, values} <- + navigate( + record, + resolved.resource, + String.split(relationship_path, ".", trim: true), + resolved + ) do + {:ok, List.wrap(values) |> List.flatten()} + end + end + end + end + + defp optional_record(source, context, opts) do + resolved = Keyword.fetch!(opts, :resolved) + filters = build_filters(source, resolved.resource, context, opts) + read_one(resolved, filters) + end + + defp required_record(source, context, opts) do + with {:ok, record} <- optional_record(source, context, opts) do + case record do + nil -> + {:error, + {:resource_not_found, source_resource(source), + record_id(source, opts[:resolved].resource, context)}} + + record -> + {:ok, record} + end + end + end + + defp read_one(resolved, filters) do + resolved.resource + |> Ash.Query.new() + |> maybe_filter(filters, resolved.resource) + |> Ash.read_one(ash_opts(resolved)) + |> authorize_record_result(resolved) + end + + defp read_records(resolved, filters) do + resolved.resource + |> Ash.Query.new() + |> maybe_filter(filters, resolved.resource) + |> Ash.read(ash_opts(resolved)) + |> authorize_records_result(resolved) + end + + defp maybe_filter(query, filters, _resource) when filters in [%{}, [], nil], do: query + + defp maybe_filter(query, filters, resource) do + normalized = + filters + |> Enum.into(%{}) + |> Enum.reduce([], fn {key, value}, acc -> + case resolve_attribute_name(resource, key) do + {:ok, attribute_name} -> Keyword.put(acc, attribute_name, value) + {:error, _} -> acc + end + end) + + if normalized == [] do + query + else + Ash.Query.filter(query, ^normalized) + end + end + + defp navigate(value, _resource, [], _resolved), do: {:ok, value} + defp navigate(nil, _resource, _parts, _resolved), do: {:ok, nil} + + defp navigate(values, resource, parts, resolved) when is_list(values) do + resolved_values = + values + |> Enum.map(fn item -> + item_resource = resource_for(item) || resource + + case navigate(item, item_resource, parts, resolved) do + {:ok, value} -> value + {:error, _reason} -> nil + end + end) + |> List.flatten() + |> Enum.reject(&is_nil/1) + + {:ok, resolved_values} + end + + defp navigate(value, resource, [part | rest], resolved) do + case resolve_relationship(resource, part) do + {:ok, relationship} -> + with {:ok, loaded} <- Ash.load(value, relationship.name, ash_opts(resolved)), + {:ok, next} <- maybe_authorize_loaded(Map.get(loaded, relationship.name), resolved) do + navigate(next, relationship.destination, rest, resolved) + end + + {:error, _} -> + next = fetch_value(value, part) + + case rest do + [] -> {:ok, next} + _ -> navigate(next, resource_for(next), rest, resolved) + end + end + end + + defp resolve_relationship(nil, _name), do: {:error, :no_resource} + + defp resolve_relationship(resource, name) do + target = to_string(name) + + case Enum.find(Ash.Resource.Info.relationships(resource), fn relationship -> + Atom.to_string(relationship.name) == target + end) do + nil -> {:error, {:unknown_relationship, resource, name}} + relationship -> {:ok, relationship} + end + end + + defp resolve_action(_resource, nil), do: {:error, :missing_action} + + defp resolve_action(resource, name) do + target = to_string(name) + + case Enum.find(Ash.Resource.Info.actions(resource), fn action -> + Atom.to_string(action.name) == target + end) do + nil -> {:error, {:unknown_action, resource, name}} + action -> {:ok, action} + end + end + + defp resolve_attribute_name(_resource, nil), do: {:error, :missing_field} + + defp resolve_attribute_name(resource, name) do + target = to_string(name) + + case Enum.find(Ash.Resource.Info.attributes(resource), fn attribute -> + Atom.to_string(attribute.name) == target + end) do + nil -> {:error, {:unknown_field, resource, name}} + attribute -> {:ok, attribute.name} + end + end + + defp source_resource(source) do + Map.get(source, :resource) || Map.get(source, "resource") + end + + defp source_field(source) do + Map.get(source, :field) || Map.get(source, "field") + end + + defp source_action(source) do + Map.get(source, :action) || Map.get(source, "action") + end + + defp source_relationship(source) do + Map.get(source, :relationship) || Map.get(source, "relationship") + end + + defp build_filters(source, resource, context, opts) do + filters = + opts + |> Keyword.get(:filters, %{}) + |> Enum.into(%{}) + + case record_id(source, resource, context) do + nil -> + filters + + id -> + primary_key = resource |> Ash.Resource.Info.primary_key() |> List.first() + Map.put(filters, primary_key, id) + end + end + + defp record_id(source, resource, context) do + Map.get(source, :id) || + Map.get(source, "id") || + context_specific_id(resource, context) + end + + defp context_specific_id(resource, context) do + params = Map.get(context, :params, %{}) + assigns = Map.get(context, :assigns, %{}) + primary_key = resource |> Ash.Resource.Info.primary_key() |> List.first() |> to_string() + short_name = resource |> Module.split() |> List.last() |> Macro.underscore() + + [primary_key, "id", "#{short_name}_id"] + |> Enum.find_value(fn key -> + atom_key = + try do + String.to_existing_atom(key) + rescue + ArgumentError -> nil + end + + Map.get(params, key) || Map.get(assigns, key) || + if(atom_key, do: Map.get(assigns, atom_key)) + end) + end + + defp ash_opts(%{domain: domain, actor: actor, tenant: tenant, authorize?: authorize?}) do + [domain: domain, actor: actor, tenant: tenant, authorize?: authorize?] + |> Enum.reject(fn {_key, value} -> is_nil(value) end) + end + + defp action_opts(resolved, action, opts) do + ash_opts(resolved) + |> Keyword.put(:action, action.name) + |> Keyword.merge(Keyword.take(opts, [:timeout])) + end + + defp create_opts(resolved, action, opts), do: action_opts(resolved, action, opts) + + defp update_opts(resolved, source, opts) do + action_name = + case source_action(source) do + nil -> + resolved.resource + |> Ash.Resource.Info.primary_action!(:update) + |> Map.get(:name) + + provided when is_atom(provided) -> + provided + + provided -> + case resolve_action(resolved.resource, provided) do + {:ok, action} -> action.name + {:error, _reason} -> provided + end + end + + ash_opts(resolved) + |> Keyword.put(:action, action_name) + |> Keyword.merge(Keyword.take(opts, [:timeout])) + end + + defp drop_primary_key(params, resource) do + primary_keys = Ash.Resource.Info.primary_key(resource) + Enum.reduce(primary_keys, params, &Map.delete(&2, &1)) + end + + defp normalize_params(params, resource, action) when is_map(params) do + allowed_names = + resource + |> Ash.Resource.Info.attributes() + |> Enum.map(& &1.name) + |> Kernel.++(Enum.map(action.arguments || [], & &1.name)) + + Enum.reduce(params, %{}, fn {key, value}, acc -> + key_string = to_string(key) + + normalized_key = + Enum.find(allowed_names, key, fn name -> + Atom.to_string(name) == key_string + end) + + Map.put(acc, normalized_key, value) + end) + end + + defp normalize_params(params, _resource, _action), do: params + + defp apply_in_memory_filters(items, filters) when filters in [%{}, [], nil], do: items + + defp apply_in_memory_filters(items, filters) do + expected = Enum.into(filters, %{}, fn {key, value} -> {to_string(key), value} end) + + Enum.filter(items, fn item -> + Enum.all?(expected, fn {key, value} -> + fetch_value(item, key) == value + end) + end) + end + + defp fetch_value(nil, _key), do: nil + + defp fetch_value(data, key) when is_map(data) do + case Map.get(data, key) do + nil -> + case Enum.find(Map.keys(data), fn existing_key -> + to_string(existing_key) == to_string(key) + end) do + nil -> nil + existing_key -> Map.get(data, existing_key) + end + + value -> + value + end + end + + defp fetch_value(_data, _key), do: nil + + defp resource_for(%{__struct__: resource_module}) do + Ash.Resource.Info.attributes(resource_module) + resource_module + rescue + _ -> nil + end + + defp resource_for(_value), do: nil + + defp matching_resources(resource_ref, context) do + target = normalize_resource_ref(resource_ref) + + context_domains(context) + |> Enum.flat_map(fn domain -> + domain + |> Ash.Domain.Info.resources() + |> Enum.filter(&resource_matches?(&1, target)) + |> Enum.map(&{domain, &1}) + end) + end + + defp unique_match([], resource_ref, _context), do: {:error, {:unknown_resource, resource_ref}} + + defp unique_match(matches, resource_ref, context) do + matches = Enum.uniq_by(matches, fn {_domain, resource} -> resource end) + + case matches do + [{domain, resource}] -> + {:ok, build_resolved(domain, resource, context)} + + _ -> + {:error, + {:ambiguous_resource, resource_ref, + Enum.map(matches, fn {_domain, resource} -> resource end)}} + end + end + + defp normalize_resource_ref(resource_ref) when is_atom(resource_ref), do: resource_ref + defp normalize_resource_ref(resource_ref), do: to_string(resource_ref) + + defp resource_matches?(resource, target) when is_atom(target), do: resource == target + + defp resource_matches?(resource, target) do + module_name = resource |> Atom.to_string() |> String.trim_leading("Elixir.") + short_name = resource |> Module.split() |> List.last() + resource_short_name = resource |> Ash.Resource.Info.short_name() |> to_string() + + target in [module_name, short_name, resource_short_name] + end + + defp context_domains(context) do + context + |> Map.get(:ash_domains, Application.get_env(:ash_ui, :ash_domains, [AshUI.Domain])) + |> List.wrap() + |> Enum.uniq() + end + + defp build_resolved(domain, resource, context) do + actor = actor(context) + + %{ + resource: resource, + domain: domain, + actor: actor, + tenant: Map.get(context, :tenant), + authorize?: Map.get(context, :authorize?, not is_nil(actor)) + } + end + + defp authorize_record_result({:ok, record}, resolved) do + case authorize_record(record, resolved, :read) do + :ok -> {:ok, record} + {:error, :unauthorized} -> {:ok, nil} + {:error, reason} -> {:error, reason} + end + end + + defp authorize_record_result(other, _resolved), do: other + + defp authorize_records_result({:ok, records}, resolved) when is_list(records) do + {:ok, Enum.filter(records, &authorized_record?(&1, resolved, :read))} + end + + defp authorize_records_result(other, _resolved), do: other + + defp maybe_authorize_loaded(value, resolved) do + {:ok, prune_unauthorized(value, resolved)} + end + + defp prune_unauthorized(values, resolved) when is_list(values) do + Enum.filter(values, &authorized_record?(&1, resolved, :read)) + end + + defp prune_unauthorized(nil, _resolved), do: nil + + defp prune_unauthorized(value, resolved) do + if authorized_record?(value, resolved, :read), do: value, else: nil + end + + defp authorize_record(record, resolved, action) do + if authorized_record?(record, resolved, action) do + :ok + else + {:error, :unauthorized} + end + end + + defp authorized_record?(nil, _resolved, _action), do: true + defp authorized_record?(_record, %{authorize?: false}, _action), do: true + defp authorized_record?(_record, %{actor: nil}, _action), do: true + + defp authorized_record?(record, resolved, action) do + case Policies.allows_record_action?(resolved.actor, record, action) do + true -> + true + + false -> + false + + :unknown -> + Ash.can?({record, action}, resolved.actor, maybe_is: false) + end + rescue + _ -> true + end +end diff --git a/lib/ash_ui/telemetry.ex b/lib/ash_ui/telemetry.ex index 26e2bb07..90256541 100644 --- a/lib/ash_ui/telemetry.ex +++ b/lib/ash_ui/telemetry.ex @@ -116,6 +116,12 @@ defmodule AshUI.Telemetry do measurements: [:count, :duration, :system_time], metadata: @common_metadata ++ [:renderer] }, + %{ + event_name: [:ash_ui, :render, :fallback], + description: "Renderer fallback selected", + measurements: [:count, :system_time], + metadata: @common_metadata ++ [:renderer, :requested_renderer, :selected_renderer] + }, %{ event_name: [:ash_ui, :render, :error], description: "Rendering failed", diff --git a/mix.exs b/mix.exs index 041adc79..d61364bc 100644 --- a/mix.exs +++ b/mix.exs @@ -33,6 +33,7 @@ defmodule AshUI.MixProject do {:jason, "~> 1.4"}, {:postgrex, ">= 0.0.0"}, {:ecto_sql, "~> 3.10"}, + {:simple_sat, "~> 0.1"}, {:telemetry, "~> 1.0"}, {:uuid, "~> 1.1"} # Note: Renderer packages (unified_iur, live_ui, web_ui, desktop_ui) will be added diff --git a/mix.lock b/mix.lock index d206d4e9..c08a7a0c 100644 --- a/mix.lock +++ b/mix.lock @@ -31,6 +31,7 @@ "reactor": {:hex, :reactor, "1.0.0", "024bd13df910bcb8c01cebed4f10bd778269a141a1c8a234e4f67796ac4883cf", [:mix], [{:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:libgraph, "~> 0.16", [hex: :libgraph, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.3 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}, {:ymlr, "~> 5.0", [hex: :ymlr, repo: "hexpm", optional: false]}], "hexpm", "ae8eb507fffc517f5aa5947db9d2ede2db8bae63b66c94ccb5a2027d30f830a0"}, "req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"}, "rewrite": {:hex, :rewrite, "1.3.0", "67448ba7975690b35ba7e7f35717efcce317dbd5963cb0577aa7325c1923121a", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "d111ac7ff3a58a802ef4f193bbd1831e00a9c57b33276e5068e8390a212714a5"}, + "simple_sat": {:hex, :simple_sat, "0.1.4", "39baf72cdca14f93c0b6ce2b6418b72bbb67da98fa9ca4384e2f79bbc299899d", [:mix], [], "hexpm", "3569b68e346a5fd7154b8d14173ff8bcc829f2eb7b088c30c3f42a383443930b"}, "sourceror": {:hex, :sourceror, "1.12.0", "da354c5f35aad3cc1132f5d5b0d8437d865e2661c263260480bab51b5eedb437", [:mix], [], "hexpm", "755703683bd014ebcd5de9acc24b68fb874a660a568d1d63f8f98cd8a6ef9cd0"}, "spark": {:hex, :spark, "2.4.1", "d6807291e74b51f6efb6dd4e0d58216ae3729d45c35c456e049556e7e946e364", [:mix], [{:igniter, ">= 0.3.64 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: true]}], "hexpm", "8b065733de9840cac584515f82182ac5ba66a973a47bc5036348dc740662b46b"}, "spitfire": {:hex, :spitfire, "0.3.10", "19aea9914132456515e8f7d592f63ab9f3130876b0252e834d2390bdd8becb24", [:mix], [], "hexpm", "6a6a5f77eb4165249c76199cd2d01fb595bac9207aed3de551918ac1c2bc9267"}, diff --git a/scripts/generate_conformance_report.sh b/scripts/generate_conformance_report.sh index 75fbd251..9734c08f 100755 --- a/scripts/generate_conformance_report.sh +++ b/scripts/generate_conformance_report.sh @@ -12,6 +12,7 @@ GENERATED_AT="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" REQ_COUNT="$(rg -c '^\| REQ-' specs/conformance/spec_conformance_matrix.md || echo 0)" SCN_MATRIX_COUNT="$(rg -o 'SCN-[0-9A-Z]+' specs/conformance/spec_conformance_matrix.md | sort -u | wc -l | tr -d ' ')" SCN_CATALOG_COUNT="$(rg -o 'SCN-[0-9A-Z]+' specs/conformance/scenario_catalog.md | sort -u | wc -l | tr -d ' ')" +SCN_TRACE_COUNT="$(rg -c '^\| SCN-' specs/conformance/scenario_test_matrix.md || echo 0)" CONFORMANCE_TEST_FILES="$(rg -l '@(module)?tag.*conformance' test || true)" if [[ -n "$CONFORMANCE_TEST_FILES" ]]; then TEST_FILE_COUNT="$(printf '%s\n' "$CONFORMANCE_TEST_FILES" | sed '/^$/d' | wc -l | tr -d ' ')" @@ -19,6 +20,12 @@ else TEST_FILE_COUNT="0" fi +if [[ "$SCN_CATALOG_COUNT" -gt 0 ]]; then + SCN_TRACE_COVERAGE="$((SCN_TRACE_COUNT * 100 / SCN_CATALOG_COUNT))" +else + SCN_TRACE_COVERAGE="0" +fi + cat > "$REPORT_DIR/report.md" < "$REPORT_DIR/report.md" < "$REPORT_DIR/report.json" < scenario -> conformance-tagged test file +- Rows marked with `-` indicate intentionally uncovered or still-undocumented areas +- The scenario test matrix is enforced by `test/ash_ui/conformance_traceability_test.exs` +- Coverage percentages should be updated whenever scenarios or conformance-tagged tests change diff --git a/specs/contracts/binding_contract.md b/specs/contracts/binding_contract.md index 81468f18..d324dc59 100644 --- a/specs/contracts/binding_contract.md +++ b/specs/contracts/binding_contract.md @@ -1,261 +1,180 @@ # Binding Contract (REQ-BIND-*) -This contract defines the normative requirements for UI.Binding semantics in the Ash UI framework. +This contract defines the normative requirements for binding semantics in Ash UI. ## Purpose -Defines the requirements for data bindings that connect UI elements to Ash resources, enabling reactive UI updates and event handling. +Bindings connect persisted UI definitions to Ash-side data, collections, and actions. They are evaluated at runtime and translated into renderer-facing state and events. ## Control Plane -**Owner**: `AshUI.Framework` (Framework Control Plane) +**Owner**: `AshUI.Framework` ## Dependencies -- REQ-RES-*: Resource definitions -- REQ-SCREEN-*: Screen context -- REQ-COMP-*: Compilation contracts +- REQ-RES-*: resource definitions +- REQ-SCREEN-*: screen runtime context +- REQ-COMP-*: compilation and validation ## Requirements ### REQ-BIND-001: Binding Definition -All bindings MUST be defined as UI.Binding resources. +All bindings MUST be persisted as `AshUI.Resources.Binding` records. ```elixir -defmodule AshUI.Bindings.UserProfile do - use Ash.Resource, - domain: AshUI.Domain, - data_layer: AshPostgres.DataLayer - - attributes do - uuid_primary_key :id - attribute :source, :string - attribute :target, :string - attribute :binding_type, :atom, constraints: [one_of: [:value, :list, :action]] - attribute :transform, :map, default: %{} - end - - relationships do - belongs_to :element, AshUI.Resources.Element - belongs_to :screen, AshUI.Resources.Screen - end +attributes do + uuid_primary_key :id + attribute :source, :map, allow_nil?: false, default: %{} + attribute :target, :string, allow_nil?: false + attribute :binding_type, :atom, constraints: [one_of: [:value, :list, :action]] + attribute :transform, :map, default: %{} + attribute :metadata, :map, default: %{} end ``` **Acceptance Criteria**: -- AC-001: Bindings use `use Ash.Resource` -- AC-002: Bindings specify source and target paths -- AC-003: Bindings specify binding type -- AC-004: Bindings are associated with elements and screens +- AC-001: Bindings use `Ash.Resource` +- AC-002: Bindings persist structured `source` and `target` values +- AC-003: Bindings declare a supported `binding_type` +- AC-004: Bindings are associated with a screen and optionally an element ### REQ-BIND-002: Binding Types Bindings MUST support three fundamental types. -**Binding Types**: - -1. **`:value`** - Single value binding - - Binds an element property to a single resource attribute - - Updates propagate bidirectionally - -2. **`:list`** - Collection binding - - Binds an element to a collection of resources - - Updates propagate from resource to element only - -3. **`:action`** - Action binding - - Binds an element event to a resource action - - Triggers the action when the element event fires +1. `:value` +2. `:list` +3. `:action` **Acceptance Criteria**: -- AC-001: Bindings declare a valid binding_type -- AC-002: Unknown binding types are rejected -- AC-003: Binding type semantics are enforced +- AC-001: Unknown binding types are rejected +- AC-002: Each type has distinct runtime semantics +- AC-003: Type-specific validation is documented ### REQ-BIND-003: Source Resolution -Bindings MUST resolve source paths to Ash resources. - -**Source Path Format**: `..` +Bindings MUST resolve a structured `source` map into Ash-side reads, collections, or actions. -**Examples**: -- `MyApp.Accounts.User.name` - Attribute binding -- `MyApp.Accounts.User.toggle_active` - Action binding -- `MyApp.Accounts.Post.[author.comments]` - Nested collection +**Supported Shapes**: +- value source: `%{"resource" => "User", "field" => "name", "id" => "user-1"}` +- list source: `%{"resource" => "AuditLog", "relationship" => "entries"}` +- action source: `%{"resource" => "Profile", "action" => "save"}` **Acceptance Criteria**: -- AC-001: Sources are validated at compilation time -- AC-002: Invalid sources produce compilation errors -- AC-003: Nested paths are fully resolved -- AC-004: Circular dependencies are detected +- AC-001: Source maps are validated before evaluation +- AC-002: Invalid source shapes produce clear errors +- AC-003: Relationship and nested traversal semantics are defined +- AC-004: Source resolution honors authorization context ### REQ-BIND-004: Target Binding -Bindings MUST bind to valid element target properties. +Bindings MUST bind to renderer-facing targets understood by the runtime and adapters. -**Target Property Format**: `element.` - -**Common Target Properties**: -- `element.value` - Display value -- `element.placeholder` - Placeholder text -- `element.disabled` - Disabled state -- `element.onClick` - Click handler +**Common Targets**: +- `value` +- `items` +- `submit` +- `content` **Acceptance Criteria**: -- AC-001: Targets are validated against element schemas -- AC-002: Invalid targets produce compilation errors -- AC-003: Target types match source types (or are coercible) -- AC-004: Multiple bindings to the same target are merged +- AC-001: Targets are validated against the bound element or runtime flow +- AC-002: Invalid targets produce descriptive errors +- AC-003: Target coercion rules are documented where allowed +- AC-004: Target names remain stable across renderer adapters ### REQ-BIND-005: Transformation -Bindings MAY include transformation rules. +Bindings MAY include ordered transformation rules. -**Transformation Types**: -- `format` - String formatting (e.g., date formatting) -- `compute` - Computed values (e.g., full name from first/last) -- `validate` - Validation rules (e.g., min/max length) -- `default` - Default values when source is nil +**Common Transform Types**: +- `format` +- `compute` +- `validate` +- `default` **Acceptance Criteria**: -- AC-001: Transformations are declared in the `transform` map -- AC-002: Transformations are applied in order -- AC-003: Transformations don't violate type constraints -- AC-004: Transformation errors are surfaced +- AC-001: Transformations are declared in persisted binding data +- AC-002: Transformations run in a defined order +- AC-003: Transformation failures surface to the runtime +- AC-004: Transformations do not silently violate type expectations ### REQ-BIND-006: Reactivity -Bindings MUST trigger reactive updates when source data changes. +Bindings MUST support re-evaluation when their source data changes. **Acceptance Criteria**: - AC-001: Source changes trigger binding re-evaluation -- AC-002: Re-evaluation updates element state -- AC-003: Update propagation is batched for performance -- AC-004: Stale bindings don't prevent new updates +- AC-002: Re-evaluation updates screen or element state +- AC-003: Update propagation can be batched +- AC-004: Failed re-evaluations do not permanently stall new updates ### REQ-BIND-007: Bidirectional Updates -`:value` bindings MUST support bidirectional updates. +`:value` bindings MUST support the UI-to-resource write path. **Acceptance Criteria**: -- AC-001: Element changes update source resources -- AC-002: Source changes update element state -- AC-003: Update cycles are prevented -- AC-004: Conflict resolution is defined +- AC-001: User input can write back to the bound resource +- AC-002: Authorization is checked before writes +- AC-003: Validation and conflict failures are surfaced clearly +- AC-004: Successful writes update runtime state ### REQ-BIND-008: Action Execution -`:action` bindings MUST execute resource actions when triggered. +`:action` bindings MUST execute Ash-side actions when triggered. **Acceptance Criteria**: -- AC-001: Action bindings receive element event data -- AC-002: Actions are executed with proper authorization -- AC-003: Action results trigger UI updates +- AC-001: Event payloads are mapped into action params +- AC-002: Authorization is checked before execution +- AC-003: Action results can update UI state - AC-004: Action errors are surfaced to the user ### REQ-BIND-009: Validation -Bindings MUST validate both configuration and runtime values. +Bindings MUST validate persisted configuration and runtime input. **Acceptance Criteria**: -- AC-001: Required binding attributes are validated -- AC-002: Source and target paths are valid -- AC-003: Transformation rules are valid -- AC-004: Runtime validation errors are user-friendly +- AC-001: Required binding attributes are enforced +- AC-002: Source and target shapes are validated +- AC-003: Transformation definitions are validated +- AC-004: Runtime validation errors remain user-friendly ### REQ-BIND-010: Observability -Bindings MUST emit telemetry events for binding operations. +Bindings MUST emit telemetry events for evaluation, update, and error flows. **Acceptance Criteria**: -- AC-001: Binding evaluation events include source and target -- AC-002: Update events include old and new values +- AC-001: Evaluation events include binding identity and target +- AC-002: Update events include result context - AC-003: Error events include binding context -- AC-004: Events follow the standard telemetry schema - -## Binding Data Flow - -```mermaid -flowchart LR - subgraph Source["Ash Resource"] - Attr["Attribute/Action"] - end - - subgraph Binding["UI.Binding"] - Resolve["Resolve Source"] - Transform["Apply Transform"] - Validate["Validate Target"] - Bind["Bind to Target"] - end - - subgraph Element["UI Element"] - Prop["Target Property"] - end - - subgraph UI["User Interface"] - Display["Display Value"] - Input["User Input"] - end - - Attr --> Resolve - Resolve --> Transform - Transform --> Validate - Validate --> Bind - Bind --> Prop - Prop --> Display - Input --> Prop - Prop -.->|"Bidirectional"| Attr - - classDef source fill:#e1f5fe,stroke:#01579b,stroke-width:2px - classDef binding fill:#fff3e0,stroke:#e65100,stroke-width:2px - classDef element fill:#f3e5f5,stroke:#4a148c,stroke-width:2px - classDef ui fill:#e8f5e9,stroke:#1b5e20,stroke-width:2px - - class Attr source - class Resolve,Transform,Validate,Bind binding - class Prop element - class Display,Input ui -``` +- AC-004: Events follow the shared telemetry schema -## Binding Lifecycle - -```mermaid -stateDiagram-v2 - [*] --> defined: Binding Created - defined --> validated: Validation Pass - defined --> error: Validation Fail - validated --> bound: Element Mounted - bound --> active: Source Available - active --> updating: Source Changed - updating --> active: Update Complete - active --> error: Update Failure - active --> unbound: Element Unmounted - unbound --> [*] - error --> [*] -``` +## Implementation Note + +The repository currently exposes the binding APIs and telemetry surface described here, but some read, write, list, and action paths are still backed by placeholder implementations. This contract describes the intended real Ash-backed behavior that the reopened Phase 3 and Phase 4 work will complete. ## Traceability -| Requirement | ADR | Component Spec | Scenarios | -|---|---|---|---| -| REQ-BIND-001 | ADR-0001 | resources/ui_binding.md | SCN-201, SCN-202 | -| REQ-BIND-002 | - | resources/ui_binding.md | SCN-203, SCN-204 | -| REQ-BIND-003 | ADR-0003 | compilation/resolver.md | SCN-205, SCN-206 | -| REQ-BIND-004 | - | compilation/validator.md | SCN-207 | -| REQ-BIND-005 | ADR-0004 | compilation/transform.md | SCN-208, SCN-209 | -| REQ-BIND-006 | ADR-0005 | runtime/reactive.md | SCN-210, SCN-211 | -| REQ-BIND-007 | ADR-0005 | runtime/reactive.md | SCN-212 | -| REQ-BIND-008 | ADR-0006 | runtime/actions.md | SCN-213, SCN-214 | -| REQ-BIND-009 | - | compilation/validator.md | SCN-215 | -| REQ-BIND-010 | - | observability_contract.md | SCN-216 | +| Requirement | Component Spec | Scenarios | +|---|---|---| +| REQ-BIND-001 | resources/ui_binding.md | SCN-006 | +| REQ-BIND-002 | resources/ui_binding.md | SCN-007, SCN-008, SCN-009 | +| REQ-BIND-003 | resources/ui_binding.md | SCN-010 | +| REQ-BIND-004 | resources/ui_binding.md | SCN-006 | +| REQ-BIND-005 | resources/ui_binding.md | SCN-010 | +| REQ-BIND-006 | phase-03-data-binding-and-signal-mapping.md | SCN-007 | +| REQ-BIND-007 | phase-03-data-binding-and-signal-mapping.md | SCN-007 | +| REQ-BIND-008 | phase-03-data-binding-and-signal-mapping.md | SCN-009 | +| REQ-BIND-009 | compilation_contract.md | SCN-042 | +| REQ-BIND-010 | observability_contract.md | SCN-101 | ## Conformance -See [conformance/spec_conformance_matrix.md](../conformance/spec_conformance_matrix.md) for complete scenario mappings. +See [spec_conformance_matrix.md](../conformance/spec_conformance_matrix.md) for the current scenario coverage baseline. ## Related Specifications -- [topology.md](../topology.md) - [resource_contract.md](resource_contract.md) - [screen_contract.md](screen_contract.md) -- [compilation_contract.md](compilation_contract.md) +- [../resources/ui_binding.md](../resources/ui_binding.md) +- [../planning/phase-03-data-binding-and-signal-mapping.md](../planning/phase-03-data-binding-and-signal-mapping.md) diff --git a/specs/contracts/resource_contract.md b/specs/contracts/resource_contract.md index bc3c85d2..bb9158e3 100644 --- a/specs/contracts/resource_contract.md +++ b/specs/contracts/resource_contract.md @@ -1,200 +1,194 @@ # Resource Contract (REQ-RES-*) -This contract defines the normative requirements for Ash Resource definitions in the Ash UI framework. +This contract defines the normative requirements for Ash UI resource definitions. ## Purpose -Defines the requirements for UI resource definitions (UI.Element, UI.Screen, UI.Binding) within the Ash Framework, ensuring consistent structure, validation, and behavior across all UI components. +Ash UI stores durable UI state as Ash resources. This contract covers the resource-backed model implemented in this repository: `AshUI.Resources.Screen`, `AshUI.Resources.Element`, and `AshUI.Resources.Binding`. ## Control Plane -**Owner**: `AshUI.Framework` (Framework Control Plane) +**Owner**: `AshUI.Framework` ## Dependencies -- Ash Framework (Core, API, JsonApi) -- Ecto +- Ash Framework +- AshPostgres - Phoenix LiveView ## Requirements ### REQ-RES-001: Resource Definition -All UI resources MUST be defined using Ash DSL extensions. - -```elixir -defmodule AshUI.Resources.Element do - use Ash.Resource, - domain: AshUI.Domain, - data_layer: AshPostgres.DataLayer - - attributes do - uuid_primary_key :id - attribute :type, :atom, constraints: [one_of: [:button, :input, :text, ...]] - attribute :props, :map, default: %{} - end -end -``` +All core UI resources MUST be defined using `Ash.Resource`, registered in `AshUI.Domain`, and backed by a persistent data layer. **Acceptance Criteria**: -- AC-001: All resources use `use Ash.Resource` -- AC-002: All resources specify a domain -- AC-003: All resources specify a data layer +- AC-001: Resources use `use Ash.Resource` +- AC-002: Resources specify `domain: AshUI.Domain` +- AC-003: Resources specify a persistent data layer ### REQ-RES-002: Type Safety -All resource attributes MUST have explicitly defined types. - -**Rationale**: Type safety prevents runtime errors and enables compile-time validation. +All persisted attributes MUST have explicit Ash types and constraints where needed. **Acceptance Criteria**: -- AC-001: Every attribute has a defined type -- AC-002: Complex types use Ash.Type modules -- AC-003: Constraints are specified where applicable +- AC-001: Every persisted attribute declares a type +- AC-002: Enum-like fields use constraints or documented value sets +- AC-003: Complex fields such as `props`, `metadata`, and `unified_dsl` use structured map types ### REQ-RES-003: Relationship Definition -Resource relationships MUST use standard Ash relationship DSL. +Resource relationships MUST use standard Ash relationship DSL and reflect the screen/element/binding hierarchy. **Acceptance Criteria**: -- AC-001: Relationships use `has_one`, `has_many`, or `belongs_to` -- AC-002: Relationship names are plural for collections -- AC-003: Foreign key attributes are explicitly defined +- AC-001: `Screen` has relationships to `Element` and `Binding` +- AC-002: `Element` belongs to `Screen` and has relationships to `Binding` +- AC-003: Foreign-key ownership is explicit in the resource definitions ### REQ-RES-004: Action Definition -Resources MUST define standard Ash actions. +Resources MUST expose baseline CRUD actions appropriate to their role in the system. **Acceptance Criteria**: -- AC-001: Primary read action is named `:read` -- AC-002: Primary create action is named `:create` -- AC-003: Primary update action is named `:update` -- AC-004: Primary destroy action is named `:destroy` +- AC-001: `Screen`, `Element`, and `Binding` expose `:read` +- AC-002: Mutable resources expose primary `:create` and `:update` +- AC-003: Destructive operations are explicit and documented +- AC-004: Supplemental read actions and filtered reads are allowed ### REQ-RES-005: Validation -Resources MUST define validation rules using Ash validations. +Resources MUST validate required attributes and structural invariants before persistence. **Acceptance Criteria**: -- AC-001: Required attributes have `allow_nil?: false` -- AC-002: Custom validations use `validate` or `change` -- AC-003: Validation errors include user-friendly messages +- AC-001: Required attributes use `allow_nil?: false` +- AC-002: Resource-specific invariants are enforced through changes or validation helpers +- AC-003: Invalid data returns descriptive Ash errors -### REQ-RES-006: Authorization +### REQ-RES-006: Authorization Boundary -All resource actions MUST be authorizable through Ash Policies. +Resources MUST participate in the authorization model, either through resource-level Ash policies or an explicit runtime authorization boundary. **Acceptance Criteria**: -- AC-001: Resources define `authorizers: [Ash.Policy.Authorizer]` -- AC-002: Policies exist for all actions -- AC-003: Policy failures result in clear error messages +- AC-001: Access to screens, elements, and bindings is not implicitly unrestricted in production flows +- AC-002: The active authorization path is documented +- AC-003: Policy or runtime authorization failures are surfaced clearly + +**Implementation Note**: +The current repository primarily enforces authorization through runtime helpers. Full resource-level `Ash.Policy.Authorizer` wiring is still being completed. -### REQ-RES-007: Metadata +### REQ-RES-007: Metadata and Versioning -Resources MUST include standard metadata attributes. +Resources MUST include timestamps and version metadata needed for cache invalidation and rollout safety. **Acceptance Criteria**: -- AC-001: Resources have `created_at` timestamp -- AC-002: Resources have `updated_at` timestamp -- AC-003: Resources have `version` attribute for optimistic locking +- AC-001: Resources have created and updated timestamps +- AC-002: Resources expose a `version` attribute +- AC-003: Version changes are available to compilation and rollout logic ### REQ-RES-008: Extensions -Resources MAY include extensions for additional behavior. +Resources MAY expose extension points or companion helpers so the compiler and runtime can layer behavior on top of persisted records. **Acceptance Criteria**: -- AC-001: Extensions are declared in the resource DSL -- AC-002: Extension behavior is documented -- AC-003: Extensions don't violate core resource contracts +- AC-001: Extension behavior is documented when present +- AC-002: Extension hooks do not break the core resource schema +- AC-003: Custom behavior respects the same validation and authorization boundaries ## Resource Types -### UI.Element (REQ-RES-ELEMENT) +### UI.Element -Atomic UI component with no children. +Atomic renderer-facing component or layout node stored as a record. **Attributes**: - `id`: UUID primary key -- `type`: Atom (component type identifier) -- `props`: Map (component properties) -- `variants`: List of atom (variant identifiers) -- `metadata`: Map (additional metadata) +- `type`: atom component identifier +- `props`: renderer-facing properties map +- `variants`: list of atoms +- `position`: integer ordering value +- `metadata`: map +- `active`: boolean +- `version`: integer **Actions**: -- `read`: Query elements -- `create`: Create new element -- `update`: Update element properties -- `destroy`: Remove element +- `read` +- `create` +- `update` +- `destroy` **Relationships**: -- `belongs_to :screen` - Parent screen -- `has_many :bindings` - Associated bindings +- `belongs_to :screen` +- `has_many :bindings` -### UI.Screen (REQ-RES-SCREEN) +### UI.Screen -Composable UI container representing a page or view. +Top-level screen record that stores the durable `unified_dsl` tree and screen metadata. **Attributes**: - `id`: UUID primary key -- `name`: String (screen identifier) -- `layout`: Atom (layout type) -- `metadata`: Map (screen metadata) -- `lifecycle_state`: Atom (state tracking) +- `name`: unique screen identifier +- `unified_dsl`: persisted screen tree +- `layout`: layout hint +- `route`: optional route +- `metadata`: map +- `active`: boolean +- `version`: integer **Actions**: -- `read`: Query screens -- `create`: Create new screen -- `update`: Update screen definition -- `destroy`: Remove screen -- `mount`: Lifecycle action for screen initialization -- `unmount`: Lifecycle action for screen cleanup +- `read` +- `create` +- `update` +- `destroy` **Relationships**: -- `has_many :elements` - Child elements -- `has_many :bindings` - Associated bindings +- `has_many :elements` +- `has_many :bindings` -### UI.Binding (REQ-RES-BINDING) +### UI.Binding -Data binding connecting UI elements to Ash resources. +Binding record connecting runtime UI targets to Ash-side data or actions. **Attributes**: - `id`: UUID primary key -- `source`: String (resource path) -- `target`: String (element property) -- `binding_type`: Atom (:value, :list, :action) -- `transform`: Map (transformation rules) +- `source`: structured map describing resource, field, relationship, or action +- `target`: renderer-facing target string such as `value`, `items`, or `submit` +- `binding_type`: atom in `[:value, :list, :action]` +- `transform`: map or ordered transform configuration +- `metadata`: map +- `active`: boolean +- `version`: integer **Actions**: -- `read`: Query bindings -- `create`: Create new binding -- `update`: Update binding configuration -- `destroy`: Remove binding -- `evaluate`: Evaluate binding against resource data +- `read` +- `create` +- `update` +- `destroy` +- filtered read actions are allowed **Relationships**: -- `belongs_to :element` - Associated element -- `belongs_to :screen` - Parent screen +- `belongs_to :element` +- `belongs_to :screen` ## Traceability | Requirement | ADR | Component Spec | Scenarios | |---|---|---|---| -| REQ-RES-001 | ADR-0001 | resources/ui_element.md | SCN-001, SCN-002 | -| REQ-RES-002 | ADR-0001 | resources/ui_element.md | SCN-003 | -| REQ-RES-003 | ADR-0002 | resources/ui_screen.md | SCN-004, SCN-005 | -| REQ-RES-004 | - | resources/ui_binding.md | SCN-006 | +| REQ-RES-001 | ADR-0001 | resources/ui_element.md, resources/ui_screen.md, resources/ui_binding.md | SCN-001, SCN-004, SCN-006 | +| REQ-RES-002 | ADR-0001 | resources/ui_element.md, resources/ui_screen.md, resources/ui_binding.md | SCN-002 | +| REQ-RES-003 | ADR-0001 | resources/ui_element.md, resources/ui_screen.md, resources/ui_binding.md | SCN-003, SCN-005 | +| REQ-RES-004 | ADR-0001 | resources/ui_screen.md, resources/ui_binding.md | SCN-004, SCN-006 | | REQ-RES-005 | - | compilation/validator.md | SCN-007 | -| REQ-RES-006 | ADR-0003 | authorization_contract.md | SCN-008, SCN-009 | -| REQ-RES-007 | - | - | SCN-010 | -| REQ-RES-008 | ADR-0004 | extension_contract.md | SCN-011 | +| REQ-RES-006 | ADR-0001 | authorization_contract.md | SCN-081, SCN-084 | +| REQ-RES-007 | ADR-0001 | resources/ui_screen.md, resources/ui_element.md, resources/ui_binding.md | SCN-010 | +| REQ-RES-008 | - | - | - | ## Conformance -See [conformance/spec_conformance_matrix.md](../conformance/spec_conformance_matrix.md) for complete scenario mappings. +See [spec_conformance_matrix.md](../conformance/spec_conformance_matrix.md) for the current coverage baseline. ## Related Specifications - [topology.md](../topology.md) - [screen_contract.md](screen_contract.md) - [binding_contract.md](binding_contract.md) -- [compilation_contract.md](compilation_contract.md) +- [../resources/README.md](../resources/README.md) diff --git a/specs/contracts/screen_contract.md b/specs/contracts/screen_contract.md index 41ba6452..6bddf260 100644 --- a/specs/contracts/screen_contract.md +++ b/specs/contracts/screen_contract.md @@ -1,216 +1,169 @@ # Screen Contract (REQ-SCREEN-*) -This contract defines the normative requirements for UI.Screen lifecycle and behavior in the Ash UI framework. +This contract defines the normative requirements for screen records and screen runtime behavior in Ash UI. ## Purpose -Defines the requirements for UI.Screen resources, which represent composable page or view containers in the Ash UI system. Screens manage the lifecycle of child elements and provide the boundary for LiveView sessions. +Screens are the top-level durable UI records in Ash UI. They store `unified_dsl`, compose child elements and bindings, and act as the boundary mounted into LiveView sessions. ## Control Plane -**Owner**: `AshUI.Runtime` (Runtime Control Plane) +**Owner**: `AshUI.Runtime` ## Dependencies -- REQ-RES-*: Resource definitions -- REQ-COMP-*: Compilation contracts -- REQ-RUNTIME-*: Runtime session management +- REQ-RES-*: resource definitions +- REQ-COMP-*: compilation contracts +- REQ-BIND-*: binding semantics ## Requirements ### REQ-SCREEN-001: Screen Definition -All screens MUST be defined as Ash Resources with the `AshUI.Screen` DSL extension. +All screens MUST be represented as persisted `AshUI.Resources.Screen` records. ```elixir -defmodule AshUI.Screens.Dashboard do - use Ash.Resource, - domain: AshUI.Domain, - data_layer: AshPostgres.DataLayer - - ui_screen do - layout :dashboard - route "/dashboard" - end - - actions do - defaults [:read, :create, :update, :destroy] - - action :mount do - argument :user_id, :uuid - run {AshUI.Screen.Actions, :mount_screen} - end - end +attributes do + uuid_primary_key :id + attribute :name, :string, allow_nil?: false + attribute :unified_dsl, :map, default: %{} + attribute :layout, :atom, default: :default + attribute :route, :string + attribute :metadata, :map, default: %{} + attribute :active, :boolean, default: true + attribute :version, :integer, default: 1 end ``` **Acceptance Criteria**: -- AC-001: Screens use `use Ash.Resource` -- AC-002: Screens include `ui_screen` DSL block -- AC-003: Screens define a layout type +- AC-001: Screens use `Ash.Resource` +- AC-002: Screens persist `name` and `unified_dsl` +- AC-003: Screens expose layout and route metadata ### REQ-SCREEN-002: Lifecycle Management -Screens MUST implement standard lifecycle actions. - -**Rationale**: Lifecycle hooks ensure proper initialization and cleanup of screen state. +Screens MUST implement a runtime lifecycle through LiveView integration and screen state management. **Lifecycle States**: -1. `:initial` - Screen definition loaded -2. `:mounting` - Screen is being mounted -3. `:mounted` - Screen is active and ready -4. `:updating` - Screen is processing updates -5. `:unmounting` - Screen is being cleaned up -6. `:unmounted` - Screen is terminated +1. loaded +2. mounting +3. mounted +4. updating +5. unmounting +6. unmounted **Acceptance Criteria**: -- AC-001: Screens implement `:mount` action -- AC-002: Screens implement `:unmount` action -- AC-003: State transitions follow the defined state machine -- AC-004: Invalid transitions are prevented +- AC-001: Screens mount through `AshUI.LiveView.Integration` +- AC-002: Runtime cleanup occurs on disconnect or explicit unmount paths +- AC-003: Invalid lifecycle transitions are handled safely +- AC-004: Lifecycle events emit telemetry ### REQ-SCREEN-003: Element Composition -Screens MUST support composition of child elements. +Screens MUST support both persisted child elements and nested structure in `unified_dsl`. **Acceptance Criteria**: -- AC-001: Screens have `has_many :elements` relationship -- AC-002: Elements maintain position/order within screens -- AC-003: Screen deletion cascades to elements -- AC-004: Elements can be added/removed from screens +- AC-001: Screens expose `has_many :elements` +- AC-002: Elements preserve ordering metadata +- AC-003: Screens expose `has_many :bindings` +- AC-004: `unified_dsl` remains the canonical nested screen tree -### REQ-SCREEN-004: Data Binding +### REQ-SCREEN-004: Data Binding Context -Screens MUST provide data binding context for child elements. +Screens MUST provide the runtime context needed for child binding resolution. **Acceptance Criteria**: -- AC-001: Screens define data sources for bindings -- AC-002: Binding resolution is scoped to screen context -- AC-003: Changes to bindings trigger screen re-renders -- AC-004: Binding errors are surfaced to the screen level +- AC-001: Binding evaluation is scoped to the mounted screen +- AC-002: Binding values are assigned into screen runtime state +- AC-003: Binding failures can be surfaced at screen level +- AC-004: Screen updates can trigger re-render paths ### REQ-SCREEN-005: Routing -Screens MUST be routable from the Phoenix endpoint. +Routable screens MUST define a stable route path. **Acceptance Criteria**: -- AC-001: Screens define a route path -- AC-002: Routes are unique across all screens -- AC-003: Route parameters are passed to mount action -- AC-004: Invalid routes return 404 +- AC-001: Routed screens persist `route` +- AC-002: Route identifiers are unique where routing is enabled +- AC-003: Route params are available to mount logic +- AC-004: Missing routes are handled explicitly by the application ### REQ-SCREEN-006: Session Isolation -Screens MUST maintain isolated state per LiveView session. +Mounted screens MUST maintain isolated state per LiveView session. **Acceptance Criteria**: -- AC-001: Each session has independent screen state -- AC-002: Session state changes don't affect other sessions -- AC-003: Session termination cleans up screen state +- AC-001: Each LiveView session has independent screen state +- AC-002: Session changes do not leak across connections +- AC-003: Disconnect cleanup releases screen-specific state - AC-004: Concurrent sessions are supported ### REQ-SCREEN-007: Event Handling -Screens MUST handle and route user events to appropriate handlers. +Screens MUST route user events through the runtime event handler boundary. **Acceptance Criteria**: -- AC-001: Screens define event handlers -- AC-002: Events are validated before processing -- AC-003: Event errors don't crash the LiveView session -- AC-004: Event responses trigger re-renders +- AC-001: Event targets can be matched to bindings or runtime handlers +- AC-002: Unknown events fail safely +- AC-003: Event errors do not crash the LiveView session +- AC-004: Successful events can trigger re-render paths ### REQ-SCREEN-008: Authorization -Screens MUST enforce authorization at mount time and for each action. +Screens MUST enforce authorization before protected mount and update flows continue. **Acceptance Criteria**: -- AC-001: Mount action checks user authorization -- AC-002: Unauthorized mount attempts redirect to login -- AC-003: Action authorization is checked before execution -- AC-004: Policy failures return user-friendly errors +- AC-001: Mount checks actor access before compilation +- AC-002: Unauthorized mounts return a safe runtime response +- AC-003: Binding and action authorization integrate with the mounted screen context +- AC-004: Authorization failures are observable ### REQ-SCREEN-009: Validation -Screens MUST validate configuration before mounting. +Screens MUST validate configuration before use. **Acceptance Criteria**: - AC-001: Invalid screen definitions fail fast -- AC-002: Validation errors are descriptive -- AC-003: Required elements are present -- AC-004: Circular dependencies are detected +- AC-002: Required fields produce descriptive errors +- AC-003: Invalid `unified_dsl` is rejected before compilation +- AC-004: Broken screen/binding relationships surface clear errors ### REQ-SCREEN-010: Observability -Screens MUST emit telemetry events for lifecycle transitions. +Screens MUST emit lifecycle telemetry. **Acceptance Criteria**: -- AC-001: Mount events include screen ID and user ID -- AC-002: Unmount events include duration and reason -- AC-003: Error events include error context -- AC-004: Events follow the standard telemetry schema - -## Lifecycle State Machine - -```mermaid -stateDiagram-v2 - [*] --> initial: Screen Defined - initial --> mounting: Mount Requested - mounting --> mounted: Mount Success - mounting --> unmounted: Mount Failure - mounted --> updating: Event Received - updating --> mounted: Update Complete - mounted --> unmounting: Unmount Requested - unmounting --> unmounted: Cleanup Complete - unmounted --> [*] -``` +- AC-001: Mount events include screen identity +- AC-002: Update events include runtime context +- AC-003: Error events include screen context +- AC-004: Events follow the shared telemetry schema -## Event Flow - -```mermaid -sequenceDiagram - participant Client as Browser - participant LV as LiveView - participant Screen as UI.Screen - participant Handler as Event Handler - participant Binding as UI.Binding - - Client->>LV: Mount Screen - LV->>Screen: :mount Action - Screen->>Screen: Validate Configuration - Screen-->>LV: Mounted State - LV-->>Client: Initial HTML - - Client->>LV: User Event - LV->>Handler: Process Event - Handler->>Binding: Resolve Bindings - Binding-->>Handler: Data - Handler->>Screen: Trigger Update - Screen-->>LV: Updated State - LV-->>Client: Patch Update -``` +## Implementation Note + +The old `ui_screen` DSL direction has been superseded in this repository by persisted screen records plus `unified_dsl`. Lifecycle is currently implemented primarily through LiveView runtime helpers rather than screen resource actions. ## Traceability -| Requirement | ADR | Component Spec | Scenarios | -|---|---|---|---| -| REQ-SCREEN-001 | ADR-0001 | resources/ui_screen.md | SCN-101, SCN-102 | -| REQ-SCREEN-002 | ADR-0002 | runtime/lifecycle.md | SCN-103, SCN-104 | -| REQ-SCREEN-003 | - | resources/ui_screen.md | SCN-105, SCN-106 | -| REQ-SCREEN-004 | ADR-0003 | resources/ui_binding.md | SCN-107, SCN-108 | -| REQ-SCREEN-005 | - | runtime/routing.md | SCN-109, SCN-110 | -| REQ-SCREEN-006 | ADR-0005 | runtime/session.md | SCN-111, SCN-112 | -| REQ-SCREEN-007 | - | runtime/events.md | SCN-113, SCN-114 | -| REQ-SCREEN-008 | ADR-0006 | authorization_contract.md | SCN-115, SCN-116 | -| REQ-SCREEN-009 | - | compilation/validator.md | SCN-117 | -| REQ-SCREEN-010 | - | observability_contract.md | SCN-118 | +| Requirement | Component Spec | Scenarios | +|---|---|---| +| REQ-SCREEN-001 | resources/ui_screen.md | SCN-004 | +| REQ-SCREEN-002 | phase-04-runtime-and-liveview-integration.md | SCN-021, SCN-022, SCN-023 | +| REQ-SCREEN-003 | resources/ui_screen.md | SCN-005 | +| REQ-SCREEN-004 | resources/ui_binding.md | SCN-006, SCN-007 | +| REQ-SCREEN-005 | resources/ui_screen.md | SCN-004 | +| REQ-SCREEN-006 | phase-04-runtime-and-liveview-integration.md | SCN-024, SCN-025 | +| REQ-SCREEN-007 | phase-04-runtime-and-liveview-integration.md | SCN-021 | +| REQ-SCREEN-008 | authorization_contract.md | SCN-081 | +| REQ-SCREEN-009 | compilation_contract.md | SCN-042 | +| REQ-SCREEN-010 | observability_contract.md | SCN-105 | ## Conformance -See [conformance/spec_conformance_matrix.md](../conformance/spec_conformance_matrix.md) for complete scenario mappings. +See [spec_conformance_matrix.md](../conformance/spec_conformance_matrix.md) for the current scenario coverage baseline. ## Related Specifications -- [topology.md](../topology.md) - [resource_contract.md](resource_contract.md) - [binding_contract.md](binding_contract.md) -- [runtime/session.md](../runtime/session.md) +- [../resources/ui_screen.md](../resources/ui_screen.md) +- [../planning/phase-04-runtime-and-liveview-integration.md](../planning/phase-04-runtime-and-liveview-integration.md) diff --git a/specs/planning/README.md b/specs/planning/README.md index 964bff7a..07d60b75 100644 --- a/specs/planning/README.md +++ b/specs/planning/README.md @@ -37,3 +37,7 @@ The plan aligns to: - unified-ui packages provide widgets, layouts, compilation, and rendering - Ash policies control access to UI resources - Data flows from Ash resources → Ash IUR → canonical IUR → renderer output + +## Status Note + +The phase files are historical planning documents, not a guarantee that every checked item is production-backed today. After the RFC-0002 re-baseline, some earlier phases have been reopened to reflect remaining gaps around resource-level authorization, real Ash-backed binding execution, and external renderer integration. diff --git a/specs/planning/phase-01-core-ash-resource-integration.md b/specs/planning/phase-01-core-ash-resource-integration.md index 105b4d76..aa425f76 100644 --- a/specs/planning/phase-01-core-ash-resource-integration.md +++ b/specs/planning/phase-01-core-ash-resource-integration.md @@ -19,22 +19,24 @@ Back to index: [README](./README.md) [ ] 1 Phase 1 - Core Ash Resource Integration Implement Ash Resources for storing unified-ui DSL definitions with database persistence, Ash actions, and policy-based authorization. + Status note: resource schemas, domain registration, migrations, and CRUD coverage exist in the repo today. This phase remains open because DSL extensions, screen lifecycle actions, direct resource-level authorizer wiring, and a few relationship/validation details are still unfinished. + [ ] 1.1 Section - UI Screen Resource Implement the UI.Screen Ash Resource for storing unified-ui screen definitions. - [ ] 1.1.1 Task - Define UI.Screen resource schema + [X] 1.1.1 Task - Define UI.Screen resource schema Create the Ash Resource for screen definitions with unified-ui DSL storage. - [ ] 1.1.1.1 Subtask - Implement `AshUI.Screen` resource with `use Ash.Resource` - [ ] 1.1.1.2 Subtask - Add `id` (UUID primary key), `name` (string), `unified_dsl` (map) attributes - [ ] 1.1.1.3 Subtask - Add `layout` (atom), `route` (string), `metadata` (map) attributes - [ ] 1.1.1.4 Subtask - Add `version` (integer) for optimistic locking and timestamps - [ ] 1.1.1.5 Subtask - Configure `AshPostgres.DataLayer` with `table: "ui_screens"` + [X] 1.1.1.1 Subtask - Implement `AshUI.Resources.Screen` resource with `use Ash.Resource` + [X] 1.1.1.2 Subtask - Add `id` (UUID primary key), `name` (string), `unified_dsl` (map) attributes + [X] 1.1.1.3 Subtask - Add `layout` (atom), `route` (string), `metadata` (map) attributes + [X] 1.1.1.4 Subtask - Add `version` (integer) and timestamps + [X] 1.1.1.5 Subtask - Configure `AshPostgres.DataLayer` with `table: "ui_screens"` [ ] 1.1.2 Task - Define UI.Screen actions Implement standard Ash actions for screen CRUD operations. - [ ] 1.1.2.1 Subtask - Add default actions: `:read`, `:create`, `:update`, `:destroy` + [X] 1.1.2.1 Subtask - Add default actions: `:read`, `:create`, `:update`, `:destroy` [ ] 1.1.2.2 Subtask - Implement `:mount` action with `user_id` and `params` arguments [ ] 1.1.2.3 Subtask - Implement `:unmount` action for cleanup [ ] 1.1.2.4 Subtask - Add action return types and error handling @@ -42,8 +44,8 @@ Back to index: [README](./README.md) [ ] 1.1.3 Task - Define UI.Screen relationships Establish relationships to elements and bindings. - [ ] 1.1.3.1 Subtask - Add `has_many :elements` relationship to `AshUI.Element` - [ ] 1.1.3.2 Subtask - Add `has_many :bindings` relationship to `AshUI.Binding` + [X] 1.1.3.1 Subtask - Add `has_many :elements` relationship to `AshUI.Element` + [X] 1.1.3.2 Subtask - Add `has_many :bindings` relationship to `AshUI.Binding` [ ] 1.1.3.3 Subtask - Configure cascade delete for child elements and bindings [ ] 1.1.4 Task - Add UI.Screen DSL extension @@ -57,21 +59,21 @@ Back to index: [README](./README.md) [ ] 1.2 Section - UI Element Resource Implement the UI.Element Ash Resource for storing unified-ui element definitions. - [ ] 1.2.1 Task - Define UI.Element resource schema + [X] 1.2.1 Task - Define UI.Element resource schema Create the Ash Resource for element definitions with unified-ui widget storage. - [ ] 1.2.1.1 Subtask - Implement `AshUI.Element` resource with `use Ash.Resource` - [ ] 1.2.1.2 Subtask - Add `id` (UUID), `type` (atom), `props` (map) attributes - [ ] 1.2.1.3 Subtask - Add `variants` (list of atoms), `position` (integer) attributes - [ ] 1.2.1.4 Subtask - Add `metadata` (map) and timestamps - [ ] 1.2.1.5 Subtask - Configure `AshPostgres.DataLayer` with `table: "ui_elements"` + [X] 1.2.1.1 Subtask - Implement `AshUI.Resources.Element` resource with `use Ash.Resource` + [X] 1.2.1.2 Subtask - Add `id` (UUID), `type` (atom), `props` (map) attributes + [X] 1.2.1.3 Subtask - Add `variants` (list of atoms), `position` (integer) attributes + [X] 1.2.1.4 Subtask - Add `metadata` (map), `active`, `version`, and timestamps + [X] 1.2.1.5 Subtask - Configure `AshPostgres.DataLayer` with `table: "ui_elements"` - [ ] 1.2.2 Task - Define UI.Element relationships + [X] 1.2.2 Task - Define UI.Element relationships Establish relationships to screen and bindings. - [ ] 1.2.2.1 Subtask - Add `belongs_to :screen` relationship to `AshUI.Screen` - [ ] 1.2.2.2 Subtask - Add `has_many :bindings` relationship to `AshUI.Binding` - [ ] 1.2.2.3 Subtask - Add foreign key `screen_id` attribute + [X] 1.2.2.1 Subtask - Add `belongs_to :screen` relationship to `AshUI.Screen` + [X] 1.2.2.2 Subtask - Add `has_many :bindings` relationship to `AshUI.Binding` + [X] 1.2.2.3 Subtask - Add foreign key `screen_id` attribute [ ] 1.2.3 Task - Add UI.Element DSL extension Create the `ui_element` DSL block for element-specific configuration. @@ -84,21 +86,21 @@ Back to index: [README](./README.md) [ ] 1.3 Section - UI Binding Resource Implement the UI.Binding Ash Resource for data binding definitions. - [ ] 1.3.1 Task - Define UI.Binding resource schema + [X] 1.3.1 Task - Define UI.Binding resource schema Create the Ash Resource for binding Ash data to UI elements. - [ ] 1.3.1.1 Subtask - Implement `AshUI.Binding` resource with `use Ash.Resource` - [ ] 1.3.1.2 Subtask - Add `id` (UUID), `source` (string), `target` (string) attributes - [ ] 1.3.1.3 Subtask - Add `binding_type` (atom), `transform` (map) attributes - [ ] 1.3.1.4 Subtask - Add `metadata` (map) and timestamps - [ ] 1.3.1.5 Subtask - Configure `AshPostgres.DataLayer` with `table: "ui_bindings"` + [X] 1.3.1.1 Subtask - Implement `AshUI.Resources.Binding` resource with `use Ash.Resource` + [X] 1.3.1.2 Subtask - Add `id` (UUID), `source` (map), `target` (string) attributes + [X] 1.3.1.3 Subtask - Add `binding_type` (atom), `transform` (map) attributes + [X] 1.3.1.4 Subtask - Add `metadata` (map), `active`, `version`, and timestamps + [X] 1.3.1.5 Subtask - Configure `AshPostgres.DataLayer` with `table: "ui_bindings"` - [ ] 1.3.2 Task - Define UI.Binding relationships + [X] 1.3.2 Task - Define UI.Binding relationships Establish relationships to element and screen. - [ ] 1.3.2.1 Subtask - Add `belongs_to :element` relationship to `AshUI.Element` - [ ] 1.3.2.2 Subtask - Add `belongs_to :screen` relationship to `AshUI.Screen` - [ ] 1.3.2.3 Subtask - Add foreign keys `element_id` and `screen_id` + [X] 1.3.2.1 Subtask - Add `belongs_to :element` relationship to `AshUI.Element` + [X] 1.3.2.2 Subtask - Add `belongs_to :screen` relationship to `AshUI.Screen` + [X] 1.3.2.3 Subtask - Add foreign keys `element_id` and `screen_id` [ ] 1.3.3 Task - Add UI.Binding DSL extension Create the `ui_binding` DSL block for binding configuration. @@ -114,45 +116,45 @@ Back to index: [README](./README.md) [ ] 1.4.1 Task - Create AshUI.Domain Define the domain containing all Ash UI resources. - [ ] 1.4.1.1 Subtask - Implement `AshUI.Domain` with `use Ash.Domain` - [ ] 1.4.1.2 Subtask - Register `AshUI.Screen`, `AshUI.Element`, `AshUI.Binding` resources + [X] 1.4.1.1 Subtask - Implement `AshUI.Domain` with `use Ash.Domain` + [X] 1.4.1.2 Subtask - Register `AshUI.Screen`, `AshUI.Element`, `AshUI.Binding` resources [ ] 1.4.1.3 Subtask - Configure domain-level authorization with `Ash.Policy.Authorizer` [ ] 1.4.2 Task - Configure resource validations Add validations for UI resource attributes. [ ] 1.4.2.1 Subtask - Validate `unified_dsl` is a valid map structure - [ ] 1.4.2.2 Subtask - Validate `binding_type` is in allowed list - [ ] 1.4.2.3 Subtask - Validate `source` format matches Ash resource paths + [X] 1.4.2.2 Subtask - Validate `binding_type` is in allowed list + [ ] 1.4.2.3 Subtask - Validate `source` format matches structured binding source maps [ ] 1.4.2.4 Subtask - Add custom validations with `validate/1` - [ ] 1.5 Section - Database Migrations + [X] 1.5 Section - Database Migrations Create Ecto migrations for UI resource tables. - [ ] 1.5.1 Task - Generate migration files + [X] 1.5.1 Task - Generate migration files Create Ecto migrations for all UI resource tables. - [ ] 1.5.1.1 Subtask - Generate migration for `ui_screens` table - [ ] 1.5.1.2 Subtask - Generate migration for `ui_elements` table - [ ] 1.5.1.3 Subtask - Generate migration for `ui_bindings` table - [ ] 1.5.1.4 Subtask - Add foreign key constraints and indexes + [X] 1.5.1.1 Subtask - Generate migration for `ui_screens` table + [X] 1.5.1.2 Subtask - Generate migration for `ui_elements` table + [X] 1.5.1.3 Subtask - Generate migration for `ui_bindings` table + [X] 1.5.1.4 Subtask - Add foreign key constraints and indexes - [ ] 1.5.2 Task - Add unique constraints and indexes + [X] 1.5.2 Task - Add unique constraints and indexes Optimize queries with proper indexes. - [ ] 1.5.2.1 Subtask - Add unique index on `ui_screens.name` - [ ] 1.5.2.2 Subtask - Add index on `ui_elements.screen_id` - [ ] 1.5.2.3 Subtask - Add composite index on `ui_bindings.element_id` and `screen_id` + [X] 1.5.2.1 Subtask - Add unique index on `ui_screens.name` + [X] 1.5.2.2 Subtask - Add index on `ui_elements.screen_id` + [X] 1.5.2.3 Subtask - Add composite index on `ui_bindings.element_id` and `screen_id` [ ] 1.6 Section - Phase 1 Integration Tests Validate Ash Resource CRUD, relationships, and DSL behavior end-to-end. - [ ] 1.6.1 Task - Resource CRUD integration scenarios + [X] 1.6.1 Task - Resource CRUD integration scenarios Verify create, read, update, and destroy operations work correctly. - [ ] 1.6.1.1 Subtask - Verify screen creation with unified_dsl storage - [ ] 1.6.1.2 Subtask - Verify element creation with screen association - [ ] 1.6.1.3 Subtask - Verify binding creation with element and screen associations + [X] 1.6.1.1 Subtask - Verify screen creation with unified_dsl storage + [X] 1.6.1.2 Subtask - Verify element creation with screen association + [X] 1.6.1.3 Subtask - Verify binding creation with element and screen associations [ ] 1.6.1.4 Subtask - Verify cascade delete from screen to elements and bindings [ ] 1.6.2 Task - DSL and validation integration scenarios @@ -163,10 +165,10 @@ Back to index: [README](./README.md) [ ] 1.6.2.3 Subtask - Verify `ui_binding` DSL validates binding types [ ] 1.6.2.4 Subtask - Verify invalid DSL options produce validation errors - [ ] 1.6.3 Task - Relationship and query integration scenarios + [X] 1.6.3 Task - Relationship and query integration scenarios Verify relationships and queries work correctly. - [ ] 1.6.3.1 Subtask - Verify loading screen with preloaded elements - [ ] 1.6.3.2 Subtask - Verify loading element with preloaded bindings - [ ] 1.6.3.3 Subtask - Verify querying elements by screen association - [ ] 1.6.3.4 Subtask - Verify querying bindings by element or screen associations + [X] 1.6.3.1 Subtask - Verify loading screen with preloaded elements + [X] 1.6.3.2 Subtask - Verify loading element with preloaded bindings + [X] 1.6.3.3 Subtask - Verify querying elements by screen association + [X] 1.6.3.4 Subtask - Verify querying bindings by element or screen associations diff --git a/specs/planning/phase-02-iur-adapter-and-canonical-conversion.md b/specs/planning/phase-02-iur-adapter-and-canonical-conversion.md index 6c2e24b6..baff2b82 100644 --- a/specs/planning/phase-02-iur-adapter-and-canonical-conversion.md +++ b/specs/planning/phase-02-iur-adapter-and-canonical-conversion.md @@ -101,11 +101,11 @@ Back to index: [README](./README.md) [X] 2.3.2.4 Subtask - Handle transformation rules in signal definition [X] 2.3.3 Task - Implement signal source resolution - Resolve Ash resource paths in binding sources. + Resolve structured binding sources into canonical signal references. - [X] 2.3.3.1 Subtask - Parse binding source path (Domain.Resource.Attribute) + [X] 2.3.3.1 Subtask - Parse structured binding source maps [X] 2.3.3.2 Subtask - Validate source exists in Ash resource definitions - [X] 2.3.3.3 Subtask - Convert source path to unified signal reference + [X] 2.3.3.3 Subtask - Convert structured source to unified signal reference [X] 2.3.3.4 Subtask - Handle nested paths and relationship traversal [X] 2.4 Section - Error Handling and Validation diff --git a/specs/planning/phase-03-data-binding-and-signal-mapping.md b/specs/planning/phase-03-data-binding-and-signal-mapping.md index 8419cea5..642d4663 100644 --- a/specs/planning/phase-03-data-binding-and-signal-mapping.md +++ b/specs/planning/phase-03-data-binding-and-signal-mapping.md @@ -15,9 +15,11 @@ Back to index: [README](./README.md) - Bidirectional bindings support read and write operations - Action bindings trigger Ash actions on UI events -[X] 3 Phase 3 - Data Binding and Signal Mapping +[ ] 3 Phase 3 - Data Binding and Signal Mapping Implement reactive data binding from Ash resources to UI elements through unified-ui signal format. + Status note: the runtime APIs, data structures, and much of the surrounding test coverage exist, but several source-resolution, write, list, and action paths are still backed by placeholders instead of real Ash calls. + [X] 3.1 Section - Binding Evaluation Implement runtime evaluation of bindings against Ash resource data. @@ -29,13 +31,13 @@ Back to index: [README](./README.md) [X] 3.1.1.3 Subtask - Return `{:ok, value}` or `{:error, reason}` [X] 3.1.1.4 Subtask - Cache evaluated values for performance - [X] 3.1.2 Task - Implement source path resolution - Resolve binding source paths to Ash resource attributes. + [ ] 3.1.2 Task - Implement source resolution against real Ash resources + Resolve structured binding sources to Ash resource attributes and relationships. - [X] 3.1.2.1 Subtask - Parse source path (Domain.Resource.Attribute) - [X] 3.1.2.2 Subtask - Load resource using `Ash.get/3` with proper authorization - [X] 3.1.2.3 Subtask - Extract attribute value from loaded resource - [X] 3.1.2.4 Subtask - Handle relationship traversal (e.g., `user.profile.name`) + [X] 3.1.2.1 Subtask - Parse structured binding sources + [ ] 3.1.2.2 Subtask - Load resource using real Ash reads with proper authorization + [ ] 3.1.2.3 Subtask - Extract attribute value from loaded resource + [ ] 3.1.2.4 Subtask - Handle relationship traversal against loaded data [X] 3.1.3 Task - Implement transformation application Apply transformation rules to resolved values. @@ -45,7 +47,7 @@ Back to index: [README](./README.md) [X] 3.1.3.3 Subtask - Apply `default` transformations when source is nil [X] 3.1.3.4 Subtask - Apply `validate` transformations and return errors - [X] 3.2 Section - Bidirectional Value Bindings + [ ] 3.2 Section - Bidirectional Value Bindings Implement two-way data binding for `:value` type bindings. [X] 3.2.1 Task - Implement read direction @@ -56,15 +58,15 @@ Back to index: [README](./README.md) [X] 3.2.1.3 Subtask - Update LiveView assigns on value change [X] 3.2.1.4 Subtask - Handle loading and error states - [X] 3.2.2 Task - Implement write direction + [ ] 3.2.2 Task - Implement write direction Flow data from UI elements to Ash resources. [X] 3.2.2.1 Subtask - Capture user input events from LiveView [X] 3.2.2.2 Subtask - Validate input data before writing - [X] 3.2.2.3 Subtask - Call `Ash.update/3` with new value - [X] 3.2.2.4 Subtask - Handle update errors and display to user + [ ] 3.2.2.3 Subtask - Call real Ash update actions with new value + [ ] 3.2.2.4 Subtask - Handle update errors and display to user - [X] 3.2.3 Task - Implement conflict resolution + [ ] 3.2.3 Task - Implement conflict resolution Handle concurrent updates to shared data. [X] 3.2.3.1 Subtask - Detect stale data with optimistic locking @@ -72,18 +74,18 @@ Back to index: [README](./README.md) [X] 3.2.3.3 Subtask - Present conflict UI to user for resolution [X] 3.2.3.4 Subtask - Emit conflict telemetry events - [X] 3.3 Section - List Bindings + [ ] 3.3 Section - List Bindings Implement collection binding for `:list` type bindings. - [X] 3.3.1 Task - Implement collection loading + [ ] 3.3.1 Task - Implement collection loading Load and bind collections of resources to UI elements. [X] 3.3.1.1 Subtask - Resolve collection source path - [X] 3.3.1.2 Subtask - Use `Ash.read/2` to load collection - [X] 3.3.1.3 Subtask - Apply pagination and filtering + [ ] 3.3.1.2 Subtask - Use real Ash reads to load collection + [ ] 3.3.1.3 Subtask - Apply pagination and filtering [X] 3.3.1.4 Subtask - Handle empty collections - [X] 3.3.2 Task - Implement collection reactivity + [ ] 3.3.2 Task - Implement collection reactivity Update UI when collection data changes. [X] 3.3.2.1 Subtask - Subscribe to collection changes @@ -91,16 +93,16 @@ Back to index: [README](./README.md) [X] 3.3.2.3 Subtask - Handle insert, update, delete operations [X] 3.3.2.4 Subtask - Maintain scroll position during updates - [X] 3.4 Section - Action Bindings + [ ] 3.4 Section - Action Bindings Implement event-driven binding for `:action` type bindings. - [X] 3.4.1 Task - Implement action execution + [ ] 3.4.1 Task - Implement action execution Execute Ash actions in response to UI events. - [X] 3.4.1.1 Subtask - Parse action source (Domain.Resource.action_name) - [X] 3.4.1.2 Subtask - Call `Ash.action/3` with event data - [X] 3.4.1.3 Subtask - Check authorization before execution - [X] 3.4.1.4 Subtask - Return action result to UI + [X] 3.4.1.1 Subtask - Parse action source + [ ] 3.4.1.2 Subtask - Call real Ash actions with event data + [ ] 3.4.1.3 Subtask - Check authorization before execution + [ ] 3.4.1.4 Subtask - Return action result to UI [X] 3.4.2 Task - Implement action event wiring Connect UI events to action bindings. @@ -129,7 +131,7 @@ Back to index: [README](./README.md) [X] 3.5.2.3 Subtask - Include required CloudEvents fields (id, source, type) [X] 3.5.2.4 Subtask - Add signal metadata for tracing - [X] 3.6 Section - Phase 3 Integration Tests + [ ] 3.6 Section - Phase 3 Integration Tests Validate binding evaluation and reactivity end-to-end. [X] 3.6.1 Task - Value binding integration scenarios diff --git a/specs/planning/phase-04-runtime-and-liveview-integration.md b/specs/planning/phase-04-runtime-and-liveview-integration.md index e561f398..6db42a73 100644 --- a/specs/planning/phase-04-runtime-and-liveview-integration.md +++ b/specs/planning/phase-04-runtime-and-liveview-integration.md @@ -15,9 +15,11 @@ Back to index: [README](./README.md) - Each LiveView session has isolated state - Events flow through LiveView `handle_event/3` and `handle_info/2` -[X] 4 Phase 4 - Runtime and LiveView Integration +[ ] 4 Phase 4 - Runtime and LiveView Integration Implement the LiveView integration layer that manages screen lifecycle, session state, and event handling. + Status note: mount-time compilation, socket assignment, and event routing are present, but full reactivity still depends on completing the real Ash-backed binding, action, and collection paths reopened in Phase 3. + [X] 4.1 Section - LiveView Mount Integration Implement screen mounting through LiveView `mount/3` callback. @@ -53,26 +55,26 @@ Back to index: [README](./README.md) [X] 4.1.4.3 Subtask - Store binding values in socket assigns [X] 4.1.4.4 Subtask - Handle binding evaluation errors - [X] 4.2 Section - LiveView Update Integration + [ ] 4.2 Section - LiveView Update Integration Implement reactive updates through LiveView `handle_info/2` callback. - [X] 4.2.1 Task - Subscribe to data changes + [ ] 4.2.1 Task - Subscribe to data changes Subscribe to Ash resource change notifications. - [X] 4.2.1.1 Subtask - Subscribe to `Ash.Notifier` for resource changes + [ ] 4.2.1.1 Subtask - Subscribe to real `Ash.Notifier` resource changes [X] 4.2.1.2 Subtask - Filter notifications to bound resources [X] 4.2.1.3 Subtask - Handle subscription messages in `handle_info/2` [X] 4.2.1.4 Subtask - Unsubscribe on unmount - [X] 4.2.2 Task - Re-render on data changes + [ ] 4.2.2 Task - Re-render on data changes Update LiveView when bound data changes. - [X] 4.2.2.1 Subtask - Re-evaluate affected bindings on notification - [X] 4.2.2.2 Subtask - Update socket assigns with new values - [X] 4.2.2.3 Subtask - Trigger LiveView re-render - [X] 4.2.2.4 Subtask - Batch multiple updates for performance + [ ] 4.2.2.1 Subtask - Re-evaluate affected bindings on notification + [ ] 4.2.2.2 Subtask - Update socket assigns with new values + [ ] 4.2.2.3 Subtask - Trigger LiveView re-render + [ ] 4.2.2.4 Subtask - Batch multiple updates for performance - [X] 4.3 Section - Event Handling Integration + [ ] 4.3 Section - Event Handling Integration Implement UI event handling through LiveView `handle_event/3` callback. [X] 4.3.1 Task - Implement event routing @@ -83,21 +85,21 @@ Back to index: [README](./README.md) [X] 4.3.1.3 Subtask - Route to appropriate handler module [X] 4.3.1.4 Subtask - Handle unknown events gracefully - [X] 4.3.2 Task - Implement value change events + [ ] 4.3.2 Task - Implement value change events Handle input value changes from form elements. [X] 4.3.2.1 Subtask - Capture `phx-blur` or `phx-change` events [X] 4.3.2.2 Subtask - Update socket assigns with new value - [X] 4.3.2.3 Subtask - Write value to Ash resource for `:value` bindings - [X] 4.3.2.4 Subtask - Handle validation errors + [ ] 4.3.2.3 Subtask - Write value to Ash resource for `:value` bindings + [ ] 4.3.2.4 Subtask - Handle validation errors - [X] 4.3.3 Task - Implement action events + [ ] 4.3.3 Task - Implement action events Handle button clicks and other action triggers. [X] 4.3.3.1 Subtask - Capture `phx-click` events from buttons [X] 4.3.3.2 Subtask - Extract action binding from event target - [X] 4.3.3.3 Subtask - Execute Ash action with parameters - [X] 4.3.3.4 Subtask - Return action result to UI + [ ] 4.3.3.3 Subtask - Execute Ash action with parameters + [ ] 4.3.3.4 Subtask - Return action result to UI [X] 4.4 Section - Screen Lifecycle Management Implement screen lifecycle hooks and state management. @@ -137,7 +139,7 @@ Back to index: [README](./README.md) [X] 4.5.2.3 Subtask - Log binding errors with context [X] 4.5.2.4 Subtask - Retry binding evaluation on recovery - [X] 4.6 Section - Phase 4 Integration Tests + [ ] 4.6 Section - Phase 4 Integration Tests Validate LiveView integration and lifecycle management end-to-end. [X] 4.6.1 Task - Mount lifecycle integration scenarios diff --git a/specs/planning/phase-05-authorization-and-policy-enforcement.md b/specs/planning/phase-05-authorization-and-policy-enforcement.md index 4a6e493d..e210b155 100644 --- a/specs/planning/phase-05-authorization-and-policy-enforcement.md +++ b/specs/planning/phase-05-authorization-and-policy-enforcement.md @@ -14,32 +14,34 @@ Back to index: [README](./README.md) - Unauthorized access returns user-friendly errors - Policy failures emit telemetry events -[X] 5 Phase 5 - Authorization and Policy Enforcement +[ ] 5 Phase 5 - Authorization and Policy Enforcement Implement Ash policy integration for UI resource access control and action authorization. - [X] 5.1 Section - Policy Definitions + Status note: runtime authorization helpers, policy helper modules, and tests exist. This phase remains open because the resource-level `Ash.Policy.Authorizer` path described here is not fully wired into the persisted Screen, Element, and Binding resources yet. + + [ ] 5.1 Section - Policy Definitions Define Ash policies for UI resources. - [X] 5.1.1 Task - Define UI.Screen policies + [ ] 5.1.1 Task - Define UI.Screen policies Add policies to screen resource for access control. - [X] 5.1.1.1 Subtask - Add `policies` block to `AshUI.Screen` resource + [ ] 5.1.1.1 Subtask - Add `policies` block to `AshUI.Screen` resource [X] 5.1.1.2 Subtask - Define `:read` policy for screen viewing [X] 5.1.1.3 Subtask - Define `:mount` policy for screen mounting [X] 5.1.1.4 Subtask - Define `:create`, `:update`, `:destroy` policies - [X] 5.1.2 Task - Define UI.Element policies + [ ] 5.1.2 Task - Define UI.Element policies Add policies to element resource for access control. - [X] 5.1.2.1 Subtask - Add `policies` block to `AshUI.Element` resource + [ ] 5.1.2.1 Subtask - Add `policies` block to `AshUI.Element` resource [X] 5.1.2.2 Subtask - Define element visibility policies [X] 5.1.2.3 Subtask - Define element modification policies [X] 5.1.2.4 Subtask - Inherit screen policies where appropriate - [X] 5.1.3 Task - Define UI.Binding policies + [ ] 5.1.3 Task - Define UI.Binding policies Add policies to binding resource for access control. - [X] 5.1.3.1 Subtask - Add `policies` block to `AshUI.Binding` resource + [ ] 5.1.3.1 Subtask - Add `policies` block to `AshUI.Binding` resource [X] 5.1.3.2 Subtask - Define binding evaluation policies [X] 5.1.3.3 Subtask - Define binding modification policies [X] 5.1.3.4 Subtask - Check data source access in binding policies diff --git a/specs/planning/phase-07-renderer-package-integration.md b/specs/planning/phase-07-renderer-package-integration.md index 920269cb..f298c7c3 100644 --- a/specs/planning/phase-07-renderer-package-integration.md +++ b/specs/planning/phase-07-renderer-package-integration.md @@ -15,6 +15,11 @@ Back to index: [README](./README.md) - Canonical IUR is passed to selected renderer - Renderers produce platform-specific output +## Current Status Note +- Ash UI now completes renderer selection, fallback handling, and integration coverage in-repo. +- External packages (`live_ui`, `web_ui`, `desktop_ui`, `unified_iur`) are still optional and not yet wired as hard dependencies in `mix.exs`. +- Until those upstream packages and APIs are stable, Ash UI relies on adapter fallback implementations for local rendering and test coverage. + [ ] 7 Phase 7 - Renderer Package Integration Integrate with external unified renderer packages (live_ui, web_ui, desktop_ui) for final output generation. @@ -102,56 +107,56 @@ Back to index: [README](./README.md) [X] - Handle platform-specific features [X] - Support desktop event handling - [ ] 7.5 Section - Renderer Selection + [X] 7.5 Section - Renderer Selection Implement automatic renderer selection based on context. - [ ] 7.5.1 Task - Implement runtime renderer selection + [X] 7.5.1 Task - Implement runtime renderer selection Select renderer based on request context. - [ ] 7.5.1.1 Subtask - Detect LiveView request → use live_ui - [ ] 7.5.1.2 Subtask - Detect HTTP request → use web_ui - [ ] 7.5.1.3 Subtask - Support explicit renderer override - [ ] 7.5.1.4 Subtask - Handle unavailable renderer gracefully + [X] 7.5.1.1 Subtask - Detect LiveView request → use live_ui + [X] 7.5.1.2 Subtask - Detect HTTP request → use web_ui + [X] 7.5.1.3 Subtask - Support explicit renderer override + [X] 7.5.1.4 Subtask - Handle unavailable renderer gracefully - [ ] 7.5.2 Task - Implement renderer fallback + [X] 7.5.2 Task - Implement renderer fallback Provide fallback when selected renderer is unavailable. - [ ] 7.5.2.1 Subtask - Fallback to alternative renderer if configured - [ ] 7.5.2.2 Subtask - Display error if no fallback available - [ ] 7.5.2.3 Subtask - Log fallback events for monitoring - [ ] 7.5.2.4 Subtask - Support per-environment renderer selection + [X] 7.5.2.1 Subtask - Fallback to alternative renderer if configured + [X] 7.5.2.2 Subtask - Display error if no fallback available + [X] 7.5.2.3 Subtask - Log fallback events for monitoring + [X] 7.5.2.4 Subtask - Support per-environment renderer selection - [ ] 7.6 Section - Phase 7 Integration Tests + [X] 7.6 Section - Phase 7 Integration Tests Validate renderer package integration end-to-end. - [ ] 7.6.1 Task - LiveUI integration scenarios + [X] 7.6.1 Task - LiveUI integration scenarios Verify live_ui renderer works correctly. - [ ] 7.6.1.1 Subtask - Verify canonical IUR renders to valid HEEx - [ ] 7.6.1.2 Subtask - Verify events are wired correctly - [ ] 7.6.1.3 Subtask - Verify reactive updates work - [ ] 7.6.1.4 Subtask - Verify LiveView patches work + [X] 7.6.1.1 Subtask - Verify canonical IUR renders to valid HEEx + [X] 7.6.1.2 Subtask - Verify events are wired correctly + [X] 7.6.1.3 Subtask - Verify reactive updates work + [X] 7.6.1.4 Subtask - Verify LiveView patches work - [ ] 7.6.2 Task - WebUI integration scenarios + [X] 7.6.2 Task - WebUI integration scenarios Verify web_ui renderer works correctly. - [ ] 7.6.2.1 Subtask - Verify canonical IUR renders to valid HTML - [ ] 7.6.2.2 Subtask - Verify Elm client integration works - [ ] 7.6.2.3 Subtask - Verify static assets are referenced correctly - [ ] 7.6.2.4 Subtask - Verify SEO tags are present + [X] 7.6.2.1 Subtask - Verify canonical IUR renders to valid HTML + [X] 7.6.2.2 Subtask - Verify Elm client integration works + [X] 7.6.2.3 Subtask - Verify static assets are referenced correctly + [X] 7.6.2.4 Subtask - Verify SEO tags are present - [ ] 7.6.3 Task - Renderer selection scenarios + [X] 7.6.3 Task - Renderer selection scenarios Verify renderer selection works correctly. - [ ] 7.6.3.1 Subtask - Verify LiveView request uses live_ui - [ ] 7.6.3.2 Subtask - Verify HTTP request uses web_ui - [ ] 7.6.3.3 Subtask - Verify explicit override is respected - [ ] 7.6.3.4 Subtask - Verify unavailable renderer shows error + [X] 7.6.3.1 Subtask - Verify LiveView request uses live_ui + [X] 7.6.3.2 Subtask - Verify HTTP request uses web_ui + [X] 7.6.3.3 Subtask - Verify explicit override is respected + [X] 7.6.3.4 Subtask - Verify unavailable renderer shows error - [ ] 7.6.4 Task - Cross-renderer scenarios + [X] 7.6.4 Task - Cross-renderer scenarios Verify UI works across different renderers. - [ ] 7.6.4.1 Subtask - Verify same IUR renders on all renderers - [ ] 7.6.4.2 Subtask - Verify renderer-specific features are isolated - [ ] 7.6.4.3 Subtask - Verify fallback behavior works - [ ] 7.6.4.4 Subtask - Verify renderer switching works + [X] 7.6.4.1 Subtask - Verify same IUR renders on all renderers + [X] 7.6.4.2 Subtask - Verify renderer-specific features are isolated + [X] 7.6.4.3 Subtask - Verify fallback behavior works + [X] 7.6.4.4 Subtask - Verify renderer switching works diff --git a/specs/resources/README.md b/specs/resources/README.md index 8f68d6dc..e4e9a482 100644 --- a/specs/resources/README.md +++ b/specs/resources/README.md @@ -1,139 +1,93 @@ # Ash UI Resources -This directory contains specifications for Ash UI resource types. +This directory contains component specs for the persisted Ash UI resource model used in this repository. ## Resource Types -### UI.Element (REQ-RES-ELEMENT) +### UI.Screen -Atomic UI component with no children. - -**Module**: `AshUI.Resources.Element` +**Module**: `AshUI.Resources.Screen` -**Purpose**: Define the smallest unit of UI - an indivisible component like a button, input, or text display. +**Purpose**: top-level durable screen record storing `unified_dsl`, route metadata, and relationships to elements and bindings. **Key Attributes**: -- `id` - UUID primary key -- `type` - Atom identifying component type (:button, :input, :text, etc.) -- `props` - Map of component properties -- `variants` - List of variant atoms for styling -- `metadata` - Additional metadata +- `id` +- `name` +- `unified_dsl` +- `layout` +- `route` +- `metadata` +- `active` +- `version` **Actions**: -- `read` - Query elements -- `create` - Create new element -- `update` - Update element properties -- `destroy` - Remove element +- `read` +- `create` +- `update` +- `destroy` **Relationships**: -- `belongs_to :screen` - Parent screen -- `has_many :bindings` - Associated bindings - -**Specifications**: [ui_element.md](ui_element.md) +- `has_many :elements` +- `has_many :bindings` -### UI.Screen (REQ-RES-SCREEN) +**Specification**: [ui_screen.md](./ui_screen.md) -Composable UI container representing a page or view. +### UI.Element -**Module**: `AshUI.Resources.Screen` +**Module**: `AshUI.Resources.Element` -**Purpose**: Define screens/pages that compose multiple elements into a complete view. +**Purpose**: persisted renderer-facing component record for relational querying and incremental composition. **Key Attributes**: -- `id` - UUID primary key -- `name` - String screen identifier -- `layout` - Atom layout type -- `metadata` - Screen metadata -- `lifecycle_state` - State tracking +- `id` +- `type` +- `props` +- `variants` +- `position` +- `metadata` +- `active` +- `version` **Actions**: -- `read` - Query screens -- `create` - Create new screen -- `update` - Update screen definition -- `destroy` - Remove screen -- `mount` - Lifecycle action for initialization -- `unmount` - Lifecycle action for cleanup +- `read` +- `create` +- `update` +- `destroy` **Relationships**: -- `has_many :elements` - Child elements -- `has_many :bindings` - Associated bindings +- `belongs_to :screen` +- `has_many :bindings` -**Specifications**: [ui_screen.md](ui_screen.md) +**Specification**: [ui_element.md](./ui_element.md) -### UI.Binding (REQ-RES-BINDING) - -Data binding connecting UI elements to Ash resources. +### UI.Binding **Module**: `AshUI.Resources.Binding` -**Purpose**: Define how UI elements connect to and interact with backend Ash resources. +**Purpose**: persisted binding record that connects runtime UI targets to Ash-side data, lists, and actions. **Key Attributes**: -- `id` - UUID primary key -- `source` - Resource path string -- `target` - Element property path -- `binding_type` - Type (:value, :list, :action) -- `transform` - Transformation rules +- `id` +- `source` +- `target` +- `binding_type` +- `transform` +- `metadata` +- `active` +- `version` **Actions**: -- `read` - Query bindings -- `create` - Create new binding -- `update` - Update binding configuration -- `destroy` - Remove binding -- `evaluate` - Evaluate binding against data +- `read` +- `create` +- `update` +- `destroy` +- filtered reads where needed **Relationships**: -- `belongs_to :element` - Associated element -- `belongs_to :screen` - Parent screen - -**Specifications**: [ui_binding.md](ui_binding.md) - -## Resource Hierarchy - -```mermaid -graph TD - Screen["UI.Screen"] - Element["UI.Element"] - Binding["UI.Binding"] - - Screen -->|"has_many"| Element - Screen -->|"has_many"| Binding - Element -->|"has_many"| Binding - Element -->|"belongs_to"| Screen - Binding -->|"belongs_to"| Element - Binding -->|"belongs_to"| Screen - - classDef screen fill:#f3e5f5,stroke:#4a148c,stroke-width:2px - classDef element fill:#e1f5fe,stroke:#01579b,stroke-width:2px - classDef binding fill:#fff3e0,stroke:#e65100,stroke-width:2px - - class Screen screen - class Element element - class Binding binding -``` - -## Standard Element Types - -| Type | Description | Props | -|---|---|---| -| `:button` | Clickable button | label, icon, disabled | -| `:input` | Text input field | value, placeholder, type | -| `:text` | Static text display | content, format | -| `:image` | Image display | src, alt, width, height | -| `:link` | Navigation link | to, label, icon | -| `:form` | Form container | action, method | -| `:table` | Data table | columns, rows | -| `:card` | Content card | title, body, footer | -| `:modal` | Modal dialog | title, content | -| `:list` | List container | items, orientation | - -## Standard Binding Types - -| Type | Direction | Description | -|---|---|---| -| `:value` | Bidirectional | Single value binding | -| `:list` | Resource → UI | Collection binding | -| `:action` | UI → Resource | Action trigger binding | +- `belongs_to :element` +- `belongs_to :screen` + +**Specification**: [ui_binding.md](./ui_binding.md) ## Related Specifications diff --git a/specs/resources/ui_binding.md b/specs/resources/ui_binding.md new file mode 100644 index 00000000..3861ba18 --- /dev/null +++ b/specs/resources/ui_binding.md @@ -0,0 +1,47 @@ +# UI.Binding Component Spec + +## Module + +`AshUI.Resources.Binding` + +## Purpose + +Defines persisted runtime bindings for value reads, list reads, and action execution. + +## Persisted Attributes + +- `id`: UUID primary key +- `source`: structured map describing resource, field, relationship, or action +- `target`: renderer-facing target string +- `binding_type`: one of `:value`, `:list`, `:action` +- `transform`: transformation configuration +- `metadata`: free-form annotations +- `active`: soft enablement flag +- `version`: update version +- `inserted_at` +- `updated_at` + +## Relationships + +- `belongs_to :element` +- `belongs_to :screen` + +## Actions + +- `read` +- `create` +- `update` +- `destroy` +- optional filtered reads + +## Runtime Role + +- evaluated by `AshUI.Runtime.BindingEvaluator` +- written through `AshUI.Runtime.BidirectionalBinding` +- action-triggered through `AshUI.Runtime.ActionBinding` +- list-oriented updates handled by `AshUI.Runtime.ListBinding` + +## Current Gaps + +- several runtime paths still use placeholder data loaders or mock action/update results +- structured source maps are the implemented baseline; older string path examples are obsolete diff --git a/specs/resources/ui_element.md b/specs/resources/ui_element.md new file mode 100644 index 00000000..f4560e73 --- /dev/null +++ b/specs/resources/ui_element.md @@ -0,0 +1,44 @@ +# UI.Element Component Spec + +## Module + +`AshUI.Resources.Element` + +## Purpose + +Defines persisted element records that support relational querying, ordering, and incremental composition alongside `Screen.unified_dsl`. + +## Persisted Attributes + +- `id`: UUID primary key +- `type`: renderer-facing component identifier +- `props`: component properties +- `variants`: variant list +- `position`: ordering value +- `metadata`: free-form annotations +- `active`: soft enablement flag +- `version`: update version +- `inserted_at` +- `updated_at` + +## Relationships + +- `belongs_to :screen` +- `has_many :bindings` + +## Actions + +- `read` +- `create` +- `update` +- `destroy` + +## Runtime Role + +- loaded when screens compile from relational resources +- used for ordering and association queries +- paired with bindings for dynamic behavior + +## Current Gaps + +- resource-level policies are still helper-based rather than fully attached to the resource DSL diff --git a/specs/resources/ui_screen.md b/specs/resources/ui_screen.md new file mode 100644 index 00000000..b44a7073 --- /dev/null +++ b/specs/resources/ui_screen.md @@ -0,0 +1,46 @@ +# UI.Screen Component Spec + +## Module + +`AshUI.Resources.Screen` + +## Purpose + +Defines the persisted top-level screen record used by compilation, runtime mounting, and renderer adaptation. + +## Persisted Attributes + +- `id`: UUID primary key +- `name`: unique screen identifier +- `unified_dsl`: nested screen tree +- `layout`: layout hint +- `route`: optional route +- `metadata`: free-form annotations +- `active`: soft enablement flag +- `version`: update version +- `inserted_at` +- `updated_at` + +## Relationships + +- `has_many :elements` +- `has_many :bindings` + +## Actions + +- `read` +- `create` +- `update` +- `destroy` + +## Runtime Role + +- loaded by `AshUI.LiveView.Integration` +- compiled by `AshUI.Compiler` +- adapted by `AshUI.Rendering.IURAdapter` +- authorized through runtime authorization helpers today + +## Current Gaps + +- resource-level `Ash.Policy.Authorizer` wiring is still pending +- lifecycle is runtime-managed rather than implemented as screen resource actions diff --git a/test/ash_ui/authorization/phase_5_integration_test.exs b/test/ash_ui/authorization/phase_5_integration_test.exs index dc816616..77291802 100644 --- a/test/ash_ui/authorization/phase_5_integration_test.exs +++ b/test/ash_ui/authorization/phase_5_integration_test.exs @@ -8,19 +8,23 @@ defmodule AshUI.Authorization.Phase5IntegrationTest do alias AshUI.Authorization.BindingPolicy alias AshUI.AuthorizationError + @moduletag :conformance + # Mock users defp build_admin(), do: %{id: "admin-1", role: :admin, active: true} defp build_user(id \\ "user-1"), do: %{id: id, role: :user, active: true} defp build_inactive(), do: %{id: "user-2", role: :user, active: false} + defp build_guest(), do: %{id: nil, role: :guest, active: true} + # Mock socket - defp build_socket(assigns) do + defp build_socket(assigns \\ %{}) do %Phoenix.LiveView.Socket{ assigns: Enum.into(assigns, %{__changed__: %{}}) } end # Mock resources - defp build_screen(opts) do + defp build_screen(opts \\ []) do Enum.into(opts, %{ id: "screen-1", name: "Test Screen", @@ -108,7 +112,7 @@ defmodule AshUI.Authorization.Phase5IntegrationTest do assert {:forbidden, reason} = Runtime.check_action_authorization(user, :update, %{}) assert reason.reason == :inactive - assert is_binary(reason.message) + assert reason.message != nil end test "partial authorization allows some fields" do @@ -146,10 +150,12 @@ defmodule AshUI.Authorization.Phase5IntegrationTest do # Redacted value should be placeholder redacted = BindingPolicy.redacted_value(binding) - assert Enum.member?(["[PROTECTED]", []], redacted) + assert redacted == "[PROTECTED]" or redacted == [] end test "cross-resource authorization works" do + user = build_user() + # Check cross-resource policy assert Policies.can_read_source(%{source: %{"resource" => "User"}}) == true assert Policies.can_write_source(%{source: %{"resource" => "User"}}) == true @@ -210,7 +216,11 @@ defmodule AshUI.Authorization.Phase5IntegrationTest do # Cache with a timestamp cache_key = Runtime.build_cache_key(user, screen, :mount) - :ets.insert(:ash_ui_auth_cache, {cache_key, :authorized, System.system_time(:second) - 1000}) + + :ets.insert( + :ash_ui_auth_cache, + {cache_key, :authorized, System.system_time(:second) - 1000} + ) # Should be expired (assuming default TTL of 300 seconds) # Since we only went back 1000 seconds, this depends on actual TTL @@ -240,8 +250,11 @@ defmodule AshUI.Authorization.Phase5IntegrationTest do assert {:error, :no_user} = Runtime.extract_user(socket) # Mount should fail - assert {:forbidden, _reason} = Runtime.check_mount_authorization(nil, screen) - assert AuthorizationError.requires_login?(AuthorizationError.unauthenticated(AshUI.Screen, :mount)) + assert {:forbidden, reason} = Runtime.check_mount_authorization(nil, screen) + + assert AuthorizationError.requires_login?( + AuthorizationError.unauthenticated(AshUI.Screen, :mount) + ) end test "action execution authorization flow" do @@ -294,7 +307,7 @@ defmodule AshUI.Authorization.Phase5IntegrationTest do error = AuthorizationError.forbidden(AshUI.Screen, :mount) page = AuthorizationError.custom_error_page(error, AshUI.Screen) - assert is_binary(page.help_url) + assert page.help_url != nil assert String.contains?(page.help_url, "/help/") end end diff --git a/test/ash_ui/authorization/resource_authorizer_test.exs b/test/ash_ui/authorization/resource_authorizer_test.exs new file mode 100644 index 00000000..f7b51978 --- /dev/null +++ b/test/ash_ui/authorization/resource_authorizer_test.exs @@ -0,0 +1,117 @@ +defmodule AshUI.Authorization.ResourceAuthorizerTest do + use AshUI.DataCase, async: false + + alias AshUI.Domain + alias AshUI.LiveView.Integration + alias AshUI.Resources.Binding + alias AshUI.Resources.Element + alias AshUI.Resources.Screen + + @moduletag :conformance + + defp build_admin(id \\ "admin-1"), do: %{id: id, role: :admin, active: true} + defp build_user(id), do: %{id: id, role: :user, active: true} + + defp assert_forbidden(result) do + assert {:error, %Ash.Error.Forbidden{}} = result + end + + test "screen mount authorization enforces owner metadata" do + {:ok, screen} = + Ash.create( + Screen, + %{ + name: "screen-#{System.unique_integer([:positive])}", + unified_dsl: %{"type" => "screen"}, + metadata: %{"owner_id" => "owner-1", "public" => false} + }, + domain: Domain + ) + + assert :ok = Integration.authorize_screen(screen, build_user("owner-1")) + + assert {:error, :unauthorized} = + Integration.authorize_screen(screen, build_user("other-user")) + + assert :ok = Integration.authorize_screen(screen, build_admin()) + end + + test "element updates are enforced by resource policy" do + {:ok, element} = + Ash.create( + Element, + %{ + type: :text, + props: %{"content" => "Restricted"}, + metadata: %{"owner_id" => "owner-1"} + }, + domain: Domain + ) + + assert {:ok, updated} = + Ash.update(element, %{position: 1}, + actor: build_user("owner-1"), + authorize?: true, + domain: Domain + ) + + assert updated.position == 1 + + assert_forbidden( + Ash.update(element, %{position: 2}, + actor: build_user("other-user"), + authorize?: true, + domain: Domain + ) + ) + + assert {:ok, admin_updated} = + Ash.update(element, %{position: 3}, + actor: build_admin(), + authorize?: true, + domain: Domain + ) + + assert admin_updated.position == 3 + end + + test "binding updates are enforced by resource policy" do + {:ok, binding} = + Ash.create( + Binding, + %{ + source: %{"resource" => "User", "field" => "name"}, + target: "profile.name", + binding_type: :value, + metadata: %{"owner_id" => "owner-1"} + }, + domain: Domain + ) + + assert {:ok, updated} = + Ash.update(binding, %{target: "profile.display_name"}, + actor: build_user("owner-1"), + authorize?: true, + domain: Domain + ) + + assert updated.target == "profile.display_name" + + assert_forbidden( + Ash.update(binding, %{target: "profile.nickname"}, + actor: build_user("other-user"), + authorize?: true, + domain: Domain + ) + ) + + assert {:ok, admin_updated} = + Ash.update(binding, %{target: "profile.admin_name"}, + actor: build_admin(), + authorize?: true, + domain: Domain + ) + + assert admin_updated.target == "profile.admin_name" + end +end diff --git a/test/ash_ui/compiler/incremental_test.exs b/test/ash_ui/compiler/incremental_test.exs index 6d0df2ae..c7ab9e45 100644 --- a/test/ash_ui/compiler/incremental_test.exs +++ b/test/ash_ui/compiler/incremental_test.exs @@ -11,7 +11,7 @@ defmodule AshUI.Compiler.IncrementalTest do {:ok, screen} = AshUI.Data.create(Screen, attrs: %{ - name: "incremental_test_screen", + name: unique_name("incremental_test_screen"), unified_dsl: %{"type" => "screen"}, layout: :row } @@ -89,7 +89,7 @@ defmodule AshUI.Compiler.IncrementalTest do {:ok, screen} = AshUI.Data.create(Screen, attrs: %{ - name: "affects_test_screen", + name: unique_name("affects_test_screen"), unified_dsl: %{"type" => "screen"}, layout: :row } @@ -110,7 +110,11 @@ defmodule AshUI.Compiler.IncrementalTest do %{screen: screen, element: element, graph: graph} end - test "returns true when element belongs to screen", %{graph: graph, element: element, screen: screen} do + test "returns true when element belongs to screen", %{ + graph: graph, + element: element, + screen: screen + } do assert Incremental.affects_screen?(graph, :element, element.id, screen.id) == true end @@ -124,7 +128,7 @@ defmodule AshUI.Compiler.IncrementalTest do {:ok, screen} = AshUI.Data.create(Screen, attrs: %{ - name: "dependents_test_screen", + name: unique_name("dependents_test_screen"), unified_dsl: %{"type" => "screen"}, layout: :row } @@ -195,7 +199,7 @@ defmodule AshUI.Compiler.IncrementalTest do {:ok, screen} = AshUI.Data.create(Screen, attrs: %{ - name: "recompile_test_screen", + name: unique_name("recompile_test_screen"), unified_dsl: %{"type" => "screen"}, layout: :row } @@ -241,4 +245,8 @@ defmodule AshUI.Compiler.IncrementalTest do assert {:ok, %{}} = Incremental.recompile_batch([]) end end + + defp unique_name(prefix) do + "#{prefix}_#{System.unique_integer([:positive])}" + end end diff --git a/test/ash_ui/compiler/phase_6_integration_test.exs b/test/ash_ui/compiler/phase_6_integration_test.exs index abcb424d..72b25ec1 100644 --- a/test/ash_ui/compiler/phase_6_integration_test.exs +++ b/test/ash_ui/compiler/phase_6_integration_test.exs @@ -6,11 +6,15 @@ defmodule AshUI.Compiler.Phase6IntegrationTest do alias AshUI.Compiler.Extensions alias AshUI.DSL.Builder + @moduletag :conformance + defp default_dsl do - Builder.row(children: [ - Builder.text("Hello, World!"), - Builder.button("Click Me") - ]) + Builder.row( + children: [ + Builder.text("Hello, World!"), + Builder.button("Click Me") + ] + ) end describe "Section 6.5.1 - DSL storage and retrieval scenarios" do @@ -23,11 +27,16 @@ defmodule AshUI.Compiler.Phase6IntegrationTest do end test "DSL builder creates nested structure" do - dsl = Builder.column(children: [ - Builder.row(children: [ - Builder.text("Nested") - ]) - ]) + dsl = + Builder.column( + children: [ + Builder.row( + children: [ + Builder.text("Nested") + ] + ) + ] + ) assert dsl.type == "column" assert length(dsl.children) == 1 @@ -58,13 +67,17 @@ defmodule AshUI.Compiler.Phase6IntegrationTest do test "complex nested screen compiles successfully" do dsl = - Builder.row(children: [ - Builder.column(children: [ - Builder.text("Nested 1"), - Builder.text("Nested 2") - ]), - Builder.button("Submit") - ]) + Builder.row( + children: [ + Builder.column( + children: [ + Builder.text("Nested 1"), + Builder.text("Nested 2") + ] + ), + Builder.button("Submit") + ] + ) {:ok, screen} = AshUI.Data.create(AshUI.Resources.Screen, @@ -268,7 +281,9 @@ defmodule AshUI.Compiler.Phase6IntegrationTest do %{name: :columns, type: :integer, default: 3} ], validate: fn _ -> :ok end, - compile: fn props, children -> %{type: "custom_layout", props: props, children: children} end + compile: fn props, children -> + %{type: "custom_layout", props: props, children: children} + end } assert :ok = Extensions.register_layout("custom:scenario_layout", definition) @@ -280,7 +295,9 @@ defmodule AshUI.Compiler.Phase6IntegrationTest do module: CustomLayout, props: [], validate: fn _ -> :ok end, - compile: fn props, children -> %{type: "custom_layout", props: props, children: children} end + compile: fn props, children -> + %{type: "custom_layout", props: props, children: children} + end } Extensions.register_layout("custom:compile_layout", definition) @@ -299,13 +316,18 @@ defmodule AshUI.Compiler.Phase6IntegrationTest do test "full DSL to IUR pipeline" do # Build DSL using builder dsl = - Builder.row(spacing: 16, children: [ - Builder.column(children: [ - Builder.text("Header", size: 24, color: "blue"), - Builder.text("Subtext", size: 14) - ]), - Builder.button("Action", on_click: "save") - ]) + Builder.row( + spacing: 16, + children: [ + Builder.column( + children: [ + Builder.text("Header", size: 24, color: "blue"), + Builder.text("Subtext", size: 14) + ] + ), + Builder.button("Action", on_click: "save") + ] + ) # Store in database {:ok, screen} = @@ -369,7 +391,8 @@ defmodule AshUI.Compiler.Phase6IntegrationTest do end stats = Compiler.cache_stats() - assert stats.hits > 5 # Most should hit cache + # Most should hit cache + assert stats.hits > 5 end end end diff --git a/test/ash_ui/compiler_test.exs b/test/ash_ui/compiler_test.exs index d7aa7b24..aa909dd5 100644 --- a/test/ash_ui/compiler_test.exs +++ b/test/ash_ui/compiler_test.exs @@ -7,6 +7,8 @@ defmodule AshUI.CompilerTest do alias AshUI.Resources.Element alias AshUI.Resources.Binding + @moduletag :conformance + describe "compile/2" do setup do {:ok, screen} = diff --git a/test/ash_ui/conformance_traceability_test.exs b/test/ash_ui/conformance_traceability_test.exs new file mode 100644 index 00000000..20a96157 --- /dev/null +++ b/test/ash_ui/conformance_traceability_test.exs @@ -0,0 +1,80 @@ +defmodule AshUI.ConformanceTraceabilityTest do + use ExUnit.Case, async: true + + @moduletag :conformance + + @catalog_path "/Users/Pascal/code/ash/ash_ui/specs/conformance/scenario_catalog.md" + @matrix_path "/Users/Pascal/code/ash/ash_ui/specs/conformance/spec_conformance_matrix.md" + @traceability_path "/Users/Pascal/code/ash/ash_ui/specs/conformance/scenario_test_matrix.md" + + test "every catalog scenario has explicit test traceability" do + catalog_scenarios = + @catalog_path + |> File.read!() + |> extract_heading_ids() + |> MapSet.new() + + traceability_scenarios = + scenario_rows() + |> Map.keys() + |> MapSet.new() + + assert MapSet.equal?(catalog_scenarios, traceability_scenarios) + end + + test "every matrix scenario is backed by traced conformance tests" do + traced_scenarios = Map.keys(scenario_rows()) |> MapSet.new() + + matrix_scenarios = + @matrix_path + |> File.read!() + |> extract_table_ids() + |> MapSet.new() + + assert MapSet.subset?(matrix_scenarios, traced_scenarios) + end + + test "every traced test file exists and is tagged for the conformance harness" do + Enum.each(scenario_rows(), fn {_scenario, files} -> + Enum.each(files, fn file -> + absolute = Path.expand(file, "/Users/Pascal/code/ash/ash_ui") + + assert File.exists?(absolute) + body = File.read!(absolute) + assert body =~ "@moduletag :conformance" + end) + end) + end + + defp scenario_rows do + @traceability_path + |> File.read!() + |> String.split("\n") + |> Enum.reduce(%{}, fn line, acc -> + case Regex.run(~r/^\|\s*(SCN-[0-9A-Z]+)\s*\|\s*[^|]+\|\s*([^|]+?)\s*\|$/, line) do + [_, scenario, files] -> + parsed_files = + files + |> String.split(",", trim: true) + |> Enum.map(&String.trim/1) + + Map.put(acc, scenario, parsed_files) + + _ -> + acc + end + end) + end + + defp extract_heading_ids(body) do + Regex.scan(~r/^####\s+(SCN-[0-9A-Z]+):/m, body, capture: :all_but_first) + |> List.flatten() + |> Enum.uniq() + end + + defp extract_table_ids(body) do + Regex.scan(~r/^\|\s*(SCN-[0-9A-Z]+)\s*\|/m, body, capture: :all_but_first) + |> List.flatten() + |> Enum.uniq() + end +end diff --git a/test/ash_ui/liveview/event_handler_test.exs b/test/ash_ui/liveview/event_handler_test.exs index f70b0fdc..c5bfa5be 100644 --- a/test/ash_ui/liveview/event_handler_test.exs +++ b/test/ash_ui/liveview/event_handler_test.exs @@ -109,7 +109,11 @@ defmodule AshUI.LiveView.EventHandlerTest do socket = build_socket( ash_ui_bindings: %{ - action1: %{id: "action1", source: %{"resource" => "User", "action" => "create"}} + action1: %{ + id: "action1", + target: "action1", + source: %{"resource" => "User", "action" => "create"} + } }, ash_ui_user: build_user() ) @@ -123,7 +127,13 @@ defmodule AshUI.LiveView.EventHandlerTest do test "returns error for unauthorized actions" do socket = build_socket( - ash_ui_bindings: %{}, + ash_ui_bindings: %{ + restricted_action: %{ + id: "restricted_action", + target: "restricted_action", + source: %{"resource" => "User", "action" => "create"} + } + }, ash_ui_user: nil ) diff --git a/test/ash_ui/liveview/liveview_integration_test.exs b/test/ash_ui/liveview/liveview_integration_test.exs index 7abc4334..ca5b8c7f 100644 --- a/test/ash_ui/liveview/liveview_integration_test.exs +++ b/test/ash_ui/liveview/liveview_integration_test.exs @@ -2,7 +2,9 @@ defmodule AshUI.LiveView.IntegrationTest do use AshUI.DataCase, async: false alias AshUI.LiveView.Integration + alias AshUI.Resources.Binding alias AshUI.Resources.Screen + alias AshUI.Test.RuntimeFixtures # Mock socket for testing defp build_socket(assigns \\ %{}) do @@ -25,6 +27,7 @@ defmodule AshUI.LiveView.IntegrationTest do %Screen{ id: id, name: "Test Screen", + metadata: %{}, elements: [] } end @@ -42,7 +45,8 @@ defmodule AshUI.LiveView.IntegrationTest do AshUI.Data.create(Screen, attrs: %{ name: "restricted_screen", - unified_dsl: %{"type" => "screen"} + unified_dsl: %{"type" => "screen"}, + metadata: %{"owner_id" => "admin-1", "public" => false} } ) @@ -80,7 +84,13 @@ defmodule AshUI.LiveView.IntegrationTest do end test "returns error for unauthorized user" do - screen = build_screen("restricted-screen") + screen = %Screen{ + id: "restricted-screen", + name: "Restricted Screen", + metadata: %{"owner_id" => "admin-1", "public" => false}, + elements: [] + } + unauthorized_user = build_user("unauthorized-user") assert {:error, :unauthorized} = Integration.authorize_screen(screen, unauthorized_user) @@ -124,6 +134,45 @@ defmodule AshUI.LiveView.IntegrationTest do assert {:ok, bindings} = Integration.evaluate_bindings(screen, socket, user, params) assert bindings == %{} end + + test "filters out bindings the user cannot read" do + {:ok, screen} = + Ash.create( + Screen, + %{ + name: "bindings_screen_#{System.unique_integer([:positive])}", + unified_dsl: %{"type" => "screen"}, + metadata: %{"public" => true} + }, domain: AshUI.Domain) + + {:ok, public_binding} = + Ash.create( + Binding, + %{ + screen_id: screen.id, + source: %{"resource" => "User", "action" => "create"}, + target: "create-user", + binding_type: :action + }, domain: AshUI.Domain) + + {:ok, restricted_binding} = + Ash.create( + Binding, + %{ + screen_id: screen.id, + source: %{"resource" => "User", "action" => "create"}, + target: "admin-only", + binding_type: :action, + metadata: %{"required_roles" => ["admin"]} + }, domain: AshUI.Domain) + + socket = RuntimeFixtures.socket() + user = build_user() + + assert {:ok, bindings} = Integration.evaluate_bindings(screen, socket, user, %{}) + assert Map.has_key?(bindings, public_binding.id) + refute Map.has_key?(bindings, restricted_binding.id) + end end describe "emit_telemetry/3" do diff --git a/test/ash_ui/liveview/phase_4_integration_test.exs b/test/ash_ui/liveview/phase_4_integration_test.exs index ef318526..9a7b263b 100644 --- a/test/ash_ui/liveview/phase_4_integration_test.exs +++ b/test/ash_ui/liveview/phase_4_integration_test.exs @@ -1,17 +1,20 @@ defmodule AshUI.LiveView.Phase4IntegrationTest do use ExUnit.Case, async: false - alias AshUI.LiveView.UpdateIntegration - alias AshUI.LiveView.EventHandler alias AshUI.LiveView.Lifecycle alias AshUI.LiveView.ErrorHandler + alias AshUI.LiveView.EventHandler + alias AshUI.LiveView.UpdateIntegration + alias AshUI.Test.RuntimeDomain + alias AshUI.Test.RuntimeFixtures + alias AshUI.Test.User @moduletag :conformance # Integration test helpers defp build_socket(assigns \\ %{}) do %Phoenix.LiveView.Socket{ - assigns: Enum.into(assigns, %{__changed__: %{}, flash: %{}}) + assigns: Enum.into(assigns, %{__changed__: %{}, flash: %{}, ash_ui_domains: [RuntimeDomain]}) } end @@ -27,8 +30,9 @@ defmodule AshUI.LiveView.Phase4IntegrationTest do # Mount should succeed with valid user # Note: In actual implementation, would need to mock Ash.get - {:ok, mounted_socket} = Lifecycle.init_session(socket, :dashboard) - assert mounted_socket.assigns[:ash_ui_session].screen_id == :dashboard + socket = socket + {:ok, socket} = Lifecycle.init_session(socket, :dashboard) + assert socket.assigns[:ash_ui_session].screen_id == :dashboard end test "screen redirects on unauthorized access" do @@ -66,39 +70,64 @@ defmodule AshUI.LiveView.Phase4IntegrationTest do ) # Compilation errors should not crash the LiveView - assert {:error, errored_socket} = ErrorHandler.handle_compilation_error(:syntax_error, socket) - assert errored_socket.assigns[:ash_ui_error].type == :compilation + assert {:error, socket} = ErrorHandler.handle_compilation_error(:syntax_error, socket) + assert socket.assigns[:ash_ui_error] != nil + assert socket.assigns[:ash_ui_error].type == :compilation end end describe "Section 4.6.2 - Event handling integration scenarios" do test "button clicks trigger Ash actions" do + fixtures = RuntimeFixtures.seed!() + socket = build_socket( ash_ui_bindings: %{ - action1: %{id: "action1", source: %{"resource" => "User", "action" => "create"}} + action1: %{ + id: "action1", + target: "action1", + source: %{"resource" => "User", "action" => "create"}, + binding_type: :action, + transform: %{ + "params" => %{ + "name" => {"event", "name"}, + "email" => {"event", "email"} + } + } + } }, - ash_ui_user: build_user() + ash_ui_user: fixtures.actor ) - params = %{"action_id" => "action1", "data" => %{"name" => "Test"}} + params = %{"action_id" => "action1", "data" => %{"name" => "Test", "email" => "test@example.com"}} - # Action events should be handled - assert {:reply, reply, _updated_socket} = EventHandler.handle_action_event(params, socket) - assert is_map(reply) + assert {:reply, reply, socket} = EventHandler.handle_action_event(params, socket) + assert reply[:status] == :ok + assert get_in(socket.assigns, [:flash, :info]) == "Action completed successfully" end test "input changes update Ash resources" do + fixtures = RuntimeFixtures.seed!() + socket = build_socket( - ash_ui_bindings: %{binding1: %{target: "input-1", value: "old"}}, - ash_ui_user: build_user() + ash_ui_bindings: %{ + binding1: %{ + id: "binding1", + target: "input-1", + source: %{"resource" => "User", "field" => "name", "id" => fixtures.user.id}, + binding_type: :value, + value: fixtures.user.name + } + }, + ash_ui_user: fixtures.actor ) params = %{"target" => "input-1", "value" => "new value"} - # Value changes should be handled - assert {:noreply, _updated_socket} = EventHandler.handle_value_change(params, socket) + assert {:noreply, socket} = EventHandler.handle_value_change(params, socket) + assert socket.assigns[:ash_ui_bindings][:binding1].value == "new value" + assert get_in(socket.assigns, [:ash_ui, :bindings, "input-1", "value"]) == "new value" end test "action errors display feedback" do @@ -111,9 +140,9 @@ defmodule AshUI.LiveView.Phase4IntegrationTest do params = %{"action_id" => "nonexistent", "data" => %{}} # Missing actions should return error - assert {:reply, reply, updated_socket} = EventHandler.handle_action_event(params, socket) + assert {:reply, reply, socket} = EventHandler.handle_action_event(params, socket) assert reply[:status] == :error - assert is_binary(updated_socket.assigns[:flash][:error]) + assert socket.assigns[:flash][:error] != nil end test "event handlers receive correct parameters" do @@ -128,22 +157,34 @@ defmodule AshUI.LiveView.Phase4IntegrationTest do describe "Section 4.6.3 - Reactivity integration scenarios" do test "UI updates when bound data changes" do + fixtures = RuntimeFixtures.seed!() + socket = build_socket( ash_ui_screen: build_screen(), - ash_ui_user: build_user(), - ash_ui_bindings: %{binding1: "old_value"} + ash_ui_user: fixtures.actor, + ash_ui_bindings: %{ + binding1: %{ + id: "binding1", + target: "input-1", + source: %{"resource" => "User", "field" => "name", "id" => fixtures.user.id}, + binding_type: :value, + value: fixtures.user.name + } + } ) + {:ok, _updated_user} = Ash.update(fixtures.user, %{name: "Reactive Update"}, domain: RuntimeDomain) + notification = %{ type: :updated, - resource: User.Profile, + resource: User, timestamp: DateTime.utc_now() } - # Resource changes should trigger updates - assert {:noreply, _updated_socket} = - UpdateIntegration.handle_resource_change(notification, socket) + assert {:noreply, socket} = UpdateIntegration.handle_resource_change(notification, socket) + assert socket.assigns[:ash_ui_bindings][:binding1].value == "Reactive Update" + assert get_in(socket.assigns, [:ash_ui, :bindings, "input-1", "value"]) == "Reactive Update" end test "multiple sessions don't interfere" do @@ -169,7 +210,7 @@ defmodule AshUI.LiveView.Phase4IntegrationTest do socket = build_socket() # Batch updates should apply all changes at once - assert {:noreply, updated_socket} = + assert {:noreply, socket} = UpdateIntegration.batch_updates(socket, fn socket -> socket |> Phoenix.Component.assign(:value1, 1) @@ -177,9 +218,9 @@ defmodule AshUI.LiveView.Phase4IntegrationTest do |> Phoenix.Component.assign(:value3, 3) end) - assert updated_socket.assigns[:value1] == 1 - assert updated_socket.assigns[:value2] == 2 - assert updated_socket.assigns[:value3] == 3 + assert socket.assigns[:value1] == 1 + assert socket.assigns[:value2] == 2 + assert socket.assigns[:value3] == 3 end test "subscriptions clean up on unmount" do @@ -189,7 +230,7 @@ defmodule AshUI.LiveView.Phase4IntegrationTest do |> elem(1) # Subscribe to some resources - {:ok, _subscription} = UpdateIntegration.subscribe(socket, User.Profile) + {:ok, _sub} = UpdateIntegration.subscribe(socket, User.Profile) # Cleanup should remove subscriptions assert :ok = UpdateIntegration.cleanup_subscriptions(socket) @@ -200,7 +241,7 @@ defmodule AshUI.LiveView.Phase4IntegrationTest do test "full screen lifecycle" do # 1. Initialize session {:ok, socket} = Lifecycle.init_session(build_socket(), :dashboard) - assert is_binary(socket.assigns[:ash_ui_session_id]) + assert socket.assigns[:ash_ui_session_id] != nil # 2. Ensure isolation socket = Lifecycle.ensure_isolation(socket) @@ -212,12 +253,11 @@ defmodule AshUI.LiveView.Phase4IntegrationTest do # 4. Register lifecycle hook socket = Lifecycle.register_hook(socket, :on_update, fn socket -> socket end) - assert [hook] = socket.assigns[:ash_ui_lifecycle_hooks][:on_update] - assert is_function(hook, 1) + assert socket.assigns[:ash_ui_lifecycle_hooks][:on_update] != nil # 5. Execute hooks socket = Lifecycle.execute_hooks(socket, :on_update) - assert match?(%Phoenix.LiveView.Socket{}, socket) + assert socket != nil # 6. Cleanup assert :ok = Lifecycle.cleanup_session(socket) @@ -245,25 +285,32 @@ defmodule AshUI.LiveView.Phase4IntegrationTest do end test "event flow from UI to Ash and back" do + fixtures = RuntimeFixtures.seed!() + socket = build_socket( ash_ui_screen: build_screen(), - ash_ui_user: build_user(), + ash_ui_user: fixtures.actor, ash_ui_bindings: %{ - binding1: %{id: "binding1", target: "input-1", value: "initial"} + binding1: %{ + id: "binding1", + target: "input-1", + source: %{"resource" => "User", "field" => "name", "id" => fixtures.user.id}, + binding_type: :value, + value: fixtures.user.name + } } ) - # 1. User changes value - {:noreply, socket} = - EventHandler.handle_value_change(%{"target" => "input-1", "value" => "changed"}, socket) + {:noreply, socket} = EventHandler.handle_value_change(%{"target" => "input-1", "value" => "changed"}, socket) + assert socket.assigns[:ash_ui_bindings][:binding1].value == "changed" + + {:ok, _updated_user} = Ash.update(fixtures.user, %{name: "server change"}, domain: RuntimeDomain) - # 2. Resource change notification - notification = %{type: :updated, resource: User.Profile, timestamp: DateTime.utc_now()} + notification = %{type: :updated, resource: User, timestamp: DateTime.utc_now()} {:noreply, socket} = UpdateIntegration.handle_resource_change(notification, socket) - # Socket should be updated - assert match?(%Phoenix.LiveView.Socket{}, socket) + assert socket.assigns[:ash_ui_bindings][:binding1].value == "server change" end end diff --git a/test/ash_ui/phase_8_integration_test.exs b/test/ash_ui/phase_8_integration_test.exs index 20a5198b..670712f3 100644 --- a/test/ash_ui/phase_8_integration_test.exs +++ b/test/ash_ui/phase_8_integration_test.exs @@ -1,6 +1,8 @@ defmodule AshUI.Phase8IntegrationTest do use AshUI.DataCase, async: false + require Ash.Query + alias AshUI.Authorization.Runtime alias AshUI.Compiler alias AshUI.DSL.Builder @@ -12,6 +14,9 @@ defmodule AshUI.Phase8IntegrationTest do alias AshUI.Rendering.WebUIAdapter alias AshUI.Resources.Screen alias AshUI.Telemetry + alias AshUI.Test.RuntimeDomain + alias AshUI.Test.RuntimeFixtures + alias AshUI.Test.User @moduletag :integration @moduletag :conformance @@ -21,7 +26,7 @@ defmodule AshUI.Phase8IntegrationTest do Compiler.init_cache() Runtime.init_cache() Telemetry.reset_metrics() - :ok + %{fixtures: RuntimeFixtures.seed!()} end describe "Section 8.6.1 - Full stack integration scenarios" do @@ -35,7 +40,7 @@ defmodule AshUI.Phase8IntegrationTest do assert mounted_socket.assigns.ash_ui_user.role == :admin end - test "8.6.1.2 - data bindings work bidirectionally" do + test "8.6.1.2 - data bindings work bidirectionally", %{fixtures: fixtures} do socket = build_socket( ash_ui_user: build_admin(), @@ -44,7 +49,7 @@ defmodule AshUI.Phase8IntegrationTest do id: "name-binding", binding_type: :value, target: "profile.name", - source: %{"resource" => "User", "field" => "name", "id" => "user-1"}, + source: %{"resource" => "User", "field" => "name", "id" => fixtures.user.id}, transform: %{"sanitize" => [%{"type" => "trim"}]} } } @@ -58,9 +63,14 @@ defmodule AshUI.Phase8IntegrationTest do assert get_in(updated_socket.assigns, [:ash_ui, :bindings, "profile.name", "value"]) == "Pascal" + + query = Ash.Query.filter(User, id == ^fixtures.user.id) + + assert {:ok, updated_user} = Ash.read_one(query, domain: RuntimeDomain) + assert updated_user.name == "Pascal" end - test "8.6.1.3 - actions execute with authorization" do + test "8.6.1.3 - actions execute with authorization", %{fixtures: fixtures} do socket = build_socket( ash_ui_user: build_admin(), @@ -69,19 +79,31 @@ defmodule AshUI.Phase8IntegrationTest do id: "save-profile", binding_type: :action, target: "submit", - source: %{"resource" => "User", "action" => "save_profile"}, - transform: %{"params" => %{"display_name" => {"event", "display_name"}}} + source: %{ + "resource" => "User", + "action" => "update", + "id" => fixtures.user.id + }, + transform: %{"params" => %{"name" => {"event", "display_name"}}} } } ) assert {:reply, %{status: :ok}, updated_socket} = EventHandler.handle_action_event( - %{"action_id" => "save-profile", "data" => %{"display_name" => "Pascal"}}, + %{ + "action_id" => "save-profile", + "data" => %{"display_name" => "Updated Pascal"} + }, socket ) assert get_in(updated_socket.assigns, [:flash, :info]) == "Action completed successfully" + + query = Ash.Query.filter(User, id == ^fixtures.user.id) + + assert {:ok, updated_user} = Ash.read_one(query, domain: RuntimeDomain) + assert updated_user.name == "Updated Pascal" end test "8.6.1.4 - rendering works across all renderers" do @@ -116,17 +138,42 @@ defmodule AshUI.Phase8IntegrationTest do test "8.6.2.2 - the traceability matrix is complete against the scenario catalog" do matrix_scns = - extract_ids(project_path("specs/conformance/spec_conformance_matrix.md"), ~r/SCN-[0-9A-Z]+/) + extract_table_ids(project_path("specs/conformance/spec_conformance_matrix.md")) |> MapSet.new() catalog_scns = - extract_ids(project_path("specs/conformance/scenario_catalog.md"), ~r/SCN-[0-9A-Z]+/) + extract_heading_ids(project_path("specs/conformance/scenario_catalog.md")) |> MapSet.new() assert MapSet.subset?(matrix_scns, catalog_scns) end - test "8.6.2.3 - conformance-tagged tests are present and targeted by the harness" do + test "8.6.2.3 - scenario test traceability is complete and targets conformance-tagged files" do + traceability_scns = + extract_table_ids(project_path("specs/conformance/scenario_test_matrix.md")) + |> MapSet.new() + + catalog_scns = + extract_heading_ids(project_path("specs/conformance/scenario_catalog.md")) + |> MapSet.new() + + assert MapSet.equal?(traceability_scns, catalog_scns) + + traceability = File.read!(project_path("specs/conformance/scenario_test_matrix.md")) + + test_files = + Regex.scan(~r/test\/ash_ui\/[A-Za-z0-9_\/\.]+\.exs/, traceability) + |> List.flatten() + |> Enum.uniq() + + Enum.each(test_files, fn file -> + absolute_path = project_path(file) + assert File.exists?(absolute_path) + assert File.read!(absolute_path) =~ "@moduletag :conformance" + end) + end + + test "8.6.2.4 - conformance-tagged tests are present and targeted by the harness" do conformance_files = run_shell!("rg -l '@(module)?tag.*conformance' test") |> String.split("\n", trim: true) @@ -137,7 +184,7 @@ defmodule AshUI.Phase8IntegrationTest do assert String.contains?(harness, "mix test --only conformance") end - test "8.6.2.4 - conformance report can be generated" do + test "8.6.2.5 - conformance report can be generated" do report_dir = temp_dir("conformance-report") output = @@ -149,6 +196,7 @@ defmodule AshUI.Phase8IntegrationTest do assert String.contains?(output, "Conformance report written") assert File.exists?(Path.join(report_dir, "report.md")) assert File.exists?(Path.join(report_dir, "report.json")) + assert File.read!(Path.join(report_dir, "report.md")) =~ "Scenario Test Matrix" end end @@ -205,7 +253,9 @@ defmodule AshUI.Phase8IntegrationTest do socket = build_socket(current_user: build_admin()) invalid_screen = %Screen{id: nil, name: nil} - assert {:error, :not_found} = Integration.mount_ui_screen(socket, :missing_phase8_screen, %{}) + assert {:error, :not_found} = + Integration.mount_ui_screen(socket, :missing_phase8_screen, %{}) + assert {:error, :invalid_screen} = Integration.compile_screen(invalid_screen) assert {:noreply, error_socket} = EventHandler.handle_event("unknown_event", %{}, socket) @@ -249,11 +299,7 @@ defmodule AshUI.Phase8IntegrationTest do end end - defp build_socket(assigns) do - %Phoenix.LiveView.Socket{ - assigns: Enum.into(assigns, %{__changed__: %{}}) - } - end + defp build_socket(assigns), do: RuntimeFixtures.socket(assigns) defp build_admin(id \\ "admin-1") do %{id: id, role: :admin, active: true} @@ -261,7 +307,8 @@ defmodule AshUI.Phase8IntegrationTest do defp create_screen(name_atom) do {:ok, screen} = - Ash.create(Screen, + Ash.create( + Screen, %{ name: Atom.to_string(name_atom), route: "/#{Atom.to_string(name_atom)}", @@ -309,6 +356,22 @@ defmodule AshUI.Phase8IntegrationTest do |> List.flatten() end + defp extract_heading_ids(path) do + path + |> File.read!() + |> then(&Regex.scan(~r/^####\s+(SCN-[0-9A-Z]+):/m, &1, capture: :all_but_first)) + |> List.flatten() + |> Enum.uniq() + end + + defp extract_table_ids(path) do + path + |> File.read!() + |> then(&Regex.scan(~r/^\|\s*(SCN-[0-9A-Z]+)\s*\|/m, &1, capture: :all_but_first)) + |> List.flatten() + |> Enum.uniq() + end + defp project_path(path) do Path.expand(path, root_dir()) end diff --git a/test/ash_ui/rendering/phase_7_integration_test.exs b/test/ash_ui/rendering/phase_7_integration_test.exs index e4a843a5..288206f2 100644 --- a/test/ash_ui/rendering/phase_7_integration_test.exs +++ b/test/ash_ui/rendering/phase_7_integration_test.exs @@ -1,7 +1,15 @@ defmodule AshUI.Rendering.Phase7IntegrationTest do use ExUnit.Case, async: true - alias AshUI.Rendering.{LiveUIAdapter, WebUIAdapter, DesktopUIAdapter, Selector, IURAdapter} + alias AshUI.Rendering.{ + DesktopUIAdapter, + IURAdapter, + LiveUIAdapter, + Registry, + Selector, + WebUIAdapter + } + alias AshUI.Compilation.IUR @moduletag :integration @@ -167,7 +175,9 @@ defmodule AshUI.Rendering.Phase7IntegrationTest do "metadata" => %{} } - assert {:ok, html} = WebUIAdapter.render(canonical_iur, elm_enabled: true, elm_module: "App") + assert {:ok, html} = + WebUIAdapter.render(canonical_iur, elm_enabled: true, elm_module: "App") + assert String.contains?(html, "elm-app") assert String.contains?(html, "Elm") end @@ -209,71 +219,63 @@ defmodule AshUI.Rendering.Phase7IntegrationTest do describe "Section 7.6.3 - Renderer selection scenarios" do test "7.6.3.1 - Verify LiveView request uses live_ui" do - request = %{ - headers: %{"accepts" => "text/vnd.phoenix.live-view"} - } + request = %{headers: %{"accepts" => "text/vnd.phoenix.live-view"}} assert {:ok, :liveview, module} = Selector.select_for_request(request) - assert module == AshUI.Rendering.LiveUIAdapter + assert {:ok, info} = Registry.renderer_info(:liveview) + assert module == info.module end test "7.6.3.2 - Verify HTTP request uses web_ui" do - request = %{ - headers: %{"accept" => "text/html"} - } + request = %{headers: %{"accept" => "text/html"}} assert {:ok, :html, module} = Selector.select_for_request(request) - assert module == AshUI.Rendering.WebUIAdapter + assert {:ok, info} = Registry.renderer_info(:html) + assert module == info.module end test "7.6.3.3 - Verify explicit override is respected" do - request = %{ - headers: %{"accept" => "text/html"} - } + request = %{headers: %{"accept" => "text/html"}} - # Override to desktop despite being an HTTP request assert {:ok, :desktop, module} = Selector.select_for_request(request, renderer: :desktop) - assert module == AshUI.Rendering.DesktopUIAdapter + assert {:ok, info} = Registry.renderer_info(:desktop) + assert module == info.module end test "7.6.3.4 - Verify unavailable renderer type returns error" do - # This tests the error handling path - though with fallback adapters - # all standard renderers are available, we test the mechanism - request = %{ - headers: %{"accept" => "text/html"} - } + request = %{headers: %{"accept" => "text/html"}} - # Test with an invalid renderer type assert {:error, _} = Selector.select_for_request(request, renderer: :invalid_type) end end describe "Section 7.6.4 - Cross-renderer scenarios" do setup do - {:ok, sample_iur: %{ - "type" => "screen", - "id" => "screen-1", - "name" => "test_screen", - "layout" => "column", - "children" => [ - %{ - "type" => "text", - "id" => "text-1", - "props" => %{"content" => "Hello World"}, - "children" => [], - "metadata" => %{} - }, - %{ - "type" => "button", - "id" => "button-1", - "props" => %{"label" => "Click"}, - "children" => [], - "metadata" => %{} - } - ], - "bindings" => [], - "metadata" => %{} - }} + {:ok, + sample_iur: %{ + "type" => "screen", + "id" => "screen-1", + "name" => "test_screen", + "layout" => "column", + "children" => [ + %{ + "type" => "text", + "id" => "text-1", + "props" => %{"content" => "Hello World"}, + "children" => [], + "metadata" => %{} + }, + %{ + "type" => "button", + "id" => "button-1", + "props" => %{"label" => "Click"}, + "children" => [], + "metadata" => %{} + } + ], + "bindings" => [], + "metadata" => %{} + }} end test "7.6.4.1 - Verify same IUR renders on all renderers", %{sample_iur: iur} do @@ -300,19 +302,16 @@ defmodule AshUI.Rendering.Phase7IntegrationTest do end test "7.6.4.3 - Verify fallback behavior works" do - request = %{ - headers: %{"accept" => "text/html"} - } + request = %{headers: %{"accept" => "text/html"}} + assert {:ok, info} = Registry.renderer_info(:html) - # Primary selection should work - assert {:ok, :html, _, false} = Selector.select_with_fallback(request) + assert {:ok, :html, module, fallback_used} = Selector.select_with_fallback(request) + assert module == info.module + assert fallback_used == (info.mode == :adapter_fallback) end test "7.6.4.4 - Verify renderer switching works" do - # Same request can be rendered with different renderers - request = %{ - headers: %{"accept" => "text/html"} - } + request = %{headers: %{"accept" => "text/html"}} assert {:ok, :html, _} = Selector.select_for_request(request) assert {:ok, :liveview, _} = Selector.select_for_request(request, renderer: :liveview) @@ -323,16 +322,17 @@ defmodule AshUI.Rendering.Phase7IntegrationTest do describe "Section 7.6 - End-to-end rendering pipeline" do test "Ash IUR converts to canonical and renders through LiveUI" do # Create a simple screen IUR without nested children - ash_iur = struct(IUR, - id: "test-id", - type: :screen, - name: "test_screen", - attributes: %{"layout" => "column"}, - children: [], - bindings: [], - metadata: %{}, - version: 1 - ) + ash_iur = + struct(IUR, + id: "test-id", + type: :screen, + name: "test_screen", + attributes: %{"layout" => "column"}, + children: [], + bindings: [], + metadata: %{}, + version: 1 + ) assert {:ok, canonical} = IURAdapter.to_canonical(ash_iur) assert {:ok, heex} = LiveUIAdapter.render(canonical) @@ -341,16 +341,17 @@ defmodule AshUI.Rendering.Phase7IntegrationTest do end test "Ash IUR converts to canonical and renders through WebUI" do - ash_iur = struct(IUR, - id: "test-id", - type: :screen, - name: "test_screen", - attributes: %{"layout" => "column"}, - children: [], - bindings: [], - metadata: %{}, - version: 1 - ) + ash_iur = + struct(IUR, + id: "test-id", + type: :screen, + name: "test_screen", + attributes: %{"layout" => "column"}, + children: [], + bindings: [], + metadata: %{}, + version: 1 + ) assert {:ok, canonical} = IURAdapter.to_canonical(ash_iur) assert {:ok, html} = WebUIAdapter.render(canonical) @@ -358,16 +359,17 @@ defmodule AshUI.Rendering.Phase7IntegrationTest do end test "Ash IUR converts to canonical and renders through DesktopUI" do - ash_iur = struct(IUR, - id: "test-id", - type: :screen, - name: "test_screen", - attributes: %{"layout" => "column"}, - children: [], - bindings: [], - metadata: %{}, - version: 1 - ) + ash_iur = + struct(IUR, + id: "test-id", + type: :screen, + name: "test_screen", + attributes: %{"layout" => "column"}, + children: [], + bindings: [], + metadata: %{}, + version: 1 + ) assert {:ok, canonical} = IURAdapter.to_canonical(ash_iur) assert {:ok, instructions} = DesktopUIAdapter.render(canonical) @@ -376,16 +378,17 @@ defmodule AshUI.Rendering.Phase7IntegrationTest do end test "Direct rendering methods work for all adapters" do - ash_iur = struct(IUR, - id: "test-id", - type: :screen, - name: "test_screen", - attributes: %{"layout" => "column"}, - children: [], - bindings: [], - metadata: %{}, - version: 1 - ) + ash_iur = + struct(IUR, + id: "test-id", + type: :screen, + name: "test_screen", + attributes: %{"layout" => "column"}, + children: [], + bindings: [], + metadata: %{}, + version: 1 + ) assert {:ok, _heex} = LiveUIAdapter.render_ash_iur(ash_iur) assert {:ok, _html} = WebUIAdapter.render_ash_iur(ash_iur) @@ -398,8 +401,8 @@ defmodule AshUI.Rendering.Phase7IntegrationTest do renderers = AshUI.Rendering.Registry.list_renderers() assert Enum.any?(renderers, fn r -> - r.type == :liveview or r.type == :html or r.type == :desktop - end) + r.type == :liveview or r.type == :html or r.type == :desktop + end) end test "Each renderer can be retrieved individually" do @@ -409,9 +412,15 @@ defmodule AshUI.Rendering.Phase7IntegrationTest do end test "Renderer availability can be checked" do - assert AshUI.Rendering.Registry.renderer_available?(:liveview) - assert AshUI.Rendering.Registry.renderer_available?(:html) - assert AshUI.Rendering.Registry.renderer_available?(:desktop) + assert is_boolean(AshUI.Rendering.Registry.renderer_available?(:liveview)) + assert is_boolean(AshUI.Rendering.Registry.renderer_available?(:html)) + assert is_boolean(AshUI.Rendering.Registry.renderer_available?(:desktop)) + end + + test "Renderer renderability can be checked independently of external packages" do + assert AshUI.Rendering.Registry.renderer_renderable?(:liveview) + assert AshUI.Rendering.Registry.renderer_renderable?(:html) + assert AshUI.Rendering.Registry.renderer_renderable?(:desktop) end test "Default renderer is available" do diff --git a/test/ash_ui/rendering/registry_test.exs b/test/ash_ui/rendering/registry_test.exs index 7fef6949..23fbc04d 100644 --- a/test/ash_ui/rendering/registry_test.exs +++ b/test/ash_ui/rendering/registry_test.exs @@ -4,17 +4,18 @@ defmodule AshUI.Rendering.RegistryTest do alias AshUI.Rendering.Registry describe "Section 7.1.3 - Renderer Registry" do - test "list_renderers returns all registered renderers" do + test "list_renderers returns all registered renderers with fallback state" do renderers = Registry.list_renderers() assert is_list(renderers) - assert length(renderers) > 0 + assert length(renderers) == 3 - # Check structure renderer = hd(renderers) assert Map.has_key?(renderer, :type) assert Map.has_key?(renderer, :module) assert Map.has_key?(renderer, :available) + assert Map.has_key?(renderer, :renderable) + assert Map.has_key?(renderer, :mode) assert Map.has_key?(renderer, :description) end @@ -42,6 +43,20 @@ defmodule AshUI.Rendering.RegistryTest do assert desktop_renderer.type == :desktop end + test "renderer_info distinguishes external availability from renderability" do + assert {:ok, info} = Registry.renderer_info(:liveview) + assert info.type == :liveview + assert is_boolean(info.available) + assert is_boolean(info.renderable) + assert info.mode in [:external, :adapter_fallback, :unavailable] + + if info.available do + assert info.mode == :external + else + assert info.mode == :adapter_fallback + end + end + test "get_renderer returns module for liveview" do assert {:ok, module} = Registry.get_renderer(:liveview) assert is_atom(module) @@ -61,32 +76,48 @@ defmodule AshUI.Rendering.RegistryTest do assert {:error, :not_found} = Registry.get_renderer(:unknown) end - test "renderer_available? checks availability" do - # Since renderer packages may not be installed, we check the function works + test "get_renderer can require an external renderer" do + assert {:ok, info} = Registry.renderer_info(:desktop, allow_adapter_fallback: false) + + if info.available do + assert {:ok, _module} = Registry.get_renderer(:desktop, allow_adapter_fallback: false) + assert info.mode == :external + else + assert {:error, :not_available} = + Registry.get_renderer(:desktop, allow_adapter_fallback: false) + + assert info.mode == :unavailable + refute info.renderable + end + end + + test "renderer_available? checks external availability only" do result = Registry.renderer_available?(:liveview) assert is_boolean(result) end + test "renderer_renderable? checks current fallback policy" do + assert Registry.renderer_renderable?(:liveview) + assert Registry.renderer_renderable?(:html) + assert Registry.renderer_renderable?(:desktop) + end + test "renderer_available? returns false for unknown type" do assert Registry.renderer_available?(:unknown) == false end + test "renderer_renderable? returns false for unknown type" do + assert Registry.renderer_renderable?(:unknown) == false + end + test "refresh updates renderer availability" do assert :ok = Registry.refresh() end test "default_renderer returns configured renderer or fallback" do - result = Registry.default_renderer() - - case result do - {:ok, type, module} -> - assert type in [:liveview, :html, :desktop] - assert is_atom(module) - - {:error, :no_renderer} -> - # Acceptable when no renderers are available - :ok - end + assert {:ok, type, module} = Registry.default_renderer() + assert type in [:liveview, :html, :desktop] + assert is_atom(module) end end @@ -105,11 +136,17 @@ defmodule AshUI.Rendering.RegistryTest do assert is_boolean(auto_detect) end + test "reads allow_adapter_fallback setting from config" do + configured = Application.get_env(:ash_ui, :rendering, []) + allow_adapter_fallback = Keyword.get(configured, :allow_adapter_fallback, true) + + assert is_boolean(allow_adapter_fallback) + end + test "reads fallback_renderer from config" do configured = Application.get_env(:ash_ui, :rendering, []) fallback = Keyword.get(configured, :fallback_renderer) - # Fallback is optional assert fallback == nil or fallback in [:liveview, :html, :desktop] end end diff --git a/test/ash_ui/rendering/selector_test.exs b/test/ash_ui/rendering/selector_test.exs index 1d25929f..b2741184 100644 --- a/test/ash_ui/rendering/selector_test.exs +++ b/test/ash_ui/rendering/selector_test.exs @@ -1,139 +1,165 @@ defmodule AshUI.Rendering.SelectorTest do - use ExUnit.Case, async: true + use ExUnit.Case, async: false - alias AshUI.Rendering.Selector + alias AshUI.Rendering.{Registry, Selector} + alias AshUI.Telemetry + + setup do + Telemetry.reset_metrics() + :ok + end describe "Section 7.5 - Renderer Selection" do test "liveview_request? returns true for LiveView request with accept header" do - request = %{ - headers: %{"accepts" => "text/vnd.phoenix.live-view"} - } - + request = %{headers: %{"accepts" => "text/vnd.phoenix.live-view"}} assert Selector.liveview_request?(request) end test "liveview_request? returns true for LiveView request with _format param" do - request = %{ - params: %{"_format" => "live"} - } - + request = %{params: %{"_format" => "live"}} assert Selector.liveview_request?(request) end test "liveview_request? returns false for standard HTTP request" do - request = %{ - headers: %{"accept" => "text/html"} - } - + request = %{headers: %{"accept" => "text/html"}} refute Selector.liveview_request?(request) end test "liveview_request? returns false when no LiveView indicators present" do request = %{params: %{}, headers: %{}} - refute Selector.liveview_request?(request) end test "http_request? returns true for HTML request" do - request = %{ - headers: %{"accept" => "text/html"} - } - + request = %{headers: %{"accept" => "text/html"}} assert Selector.http_request?(request) end test "http_request? returns false for LiveView request" do - request = %{ - headers: %{"accepts" => "text/vnd.phoenix.live-view"} - } - + request = %{headers: %{"accepts" => "text/vnd.phoenix.live-view"}} refute Selector.http_request?(request) end test "select_for_request selects liveview renderer for LiveView request" do - request = %{ - headers: %{"accepts" => "text/vnd.phoenix.live-view"} - } + request = %{headers: %{"accepts" => "text/vnd.phoenix.live-view"}} assert {:ok, :liveview, module} = Selector.select_for_request(request) - assert is_atom(module) + assert {:ok, info} = Registry.renderer_info(:liveview) + assert module == info.module end test "select_for_request selects html renderer for HTTP request" do - request = %{ - headers: %{"accept" => "text/html"} - } + request = %{headers: %{"accept" => "text/html"}} assert {:ok, :html, module} = Selector.select_for_request(request) - assert is_atom(module) + assert {:ok, info} = Registry.renderer_info(:html) + assert module == info.module end test "select_for_request respects explicit renderer override" do - request = %{ - headers: %{"accept" => "text/html"} - } + request = %{headers: %{"accept" => "text/html"}} assert {:ok, :liveview, module} = Selector.select_for_request(request, renderer: :liveview) - assert is_atom(module) + assert {:ok, info} = Registry.renderer_info(:liveview) + assert module == info.module end test "select_for_request accepts available renderer type" do - request = %{ - headers: %{"accept" => "text/html"} - } + request = %{headers: %{"accept" => "text/html"}} - assert {:ok, :desktop, _module} = - Selector.select_for_request(request, renderer: :desktop) + assert {:ok, :desktop, module} = Selector.select_for_request(request, renderer: :desktop) + assert {:ok, info} = Registry.renderer_info(:desktop) + assert module == info.module end - test "select_for_request selects renderer from X-Renderer header" do - request = %{ - headers: %{"x-renderer" => "liveview"} - } + test "select_for_request can require an external renderer" do + request = %{headers: %{"accept" => "text/html"}} + assert {:ok, info} = Registry.renderer_info(:desktop, allow_adapter_fallback: false) - assert {:ok, :liveview, _module} = Selector.select_for_request(request) + result = + Selector.select_for_request(request, renderer: :desktop, allow_adapter_fallback: false) + + if info.available do + assert {:ok, :desktop, module} = result + assert module == info.module + else + assert {:error, {:renderer_not_available, :desktop}} = result + end end - test "select_for_request selects html renderer from 'html' header value" do - request = %{ - headers: %{"x-renderer" => "html"} - } + test "select_for_request selects renderer from X-Renderer header" do + request = %{headers: %{"x-renderer" => "liveview"}} + assert {:ok, :liveview, _module} = Selector.select_for_request(request) + end + test "select_for_request selects html renderer from html header value" do + request = %{headers: %{"x-renderer" => "html"}} assert {:ok, :html, _module} = Selector.select_for_request(request) end test "select_for_request ignores header when ignore_headers option is true" do - request = %{ - headers: %{"x-renderer" => "liveview", "accept" => "text/html"} - } - + request = %{headers: %{"x-renderer" => "liveview", "accept" => "text/html"}} assert {:ok, :html, _module} = Selector.select_for_request(request, ignore_headers: true) end - test "select_with_fallback returns selected renderer when available" do - request = %{ - headers: %{"accept" => "text/html"} - } + test "select_with_fallback reports adapter fallback usage" do + request = %{headers: %{"accept" => "text/html"}} + assert {:ok, info} = Registry.renderer_info(:html) - assert {:ok, :html, _module, false} = Selector.select_with_fallback(request) + assert {:ok, :html, module, fallback_used} = Selector.select_with_fallback(request) + assert module == info.module + assert fallback_used == (info.mode == :adapter_fallback) end - test "select_with_fallback returns fallback when primary unavailable" do - request = %{ - headers: %{"x-renderer" => "desktop"} - } + test "select_with_fallback can switch renderer types when primary is unavailable" do + request = %{headers: %{"x-renderer" => "desktop"}} + + result = + Selector.select_with_fallback( + request, + allow_adapter_fallback: false, + fallback_renderer: :html, + fallback_allow_adapter_fallback: true + ) + + assert {:ok, desktop_info} = Registry.renderer_info(:desktop, allow_adapter_fallback: false) - result = Selector.select_with_fallback(request) - assert match?({:ok, _, _, _}, result) + if desktop_info.available do + assert {:ok, :desktop, module, false} = result + assert module == desktop_info.module + else + assert {:ok, :html, module, true} = result + assert {:ok, html_info} = Registry.renderer_info(:html) + assert module == html_info.module + end end - test "select_with_fallback includes from_cache flag" do - request = %{ - headers: %{"accept" => "text/html"} - } + test "select_with_fallback falls back from unknown renderer header" do + request = %{headers: %{"x-renderer" => "printer", "accept" => "text/html"}} - assert {:ok, _, _, from_cache} = Selector.select_with_fallback(request) - assert is_boolean(from_cache) + assert {:ok, :html, module, true} = Selector.select_with_fallback(request) + assert {:ok, html_info} = Registry.renderer_info(:html) + assert module == html_info.module + end + + test "select_with_fallback records fallback telemetry" do + request = %{headers: %{"accept" => "text/html"}} + + assert {:ok, _type, _module, _fallback_used} = Selector.select_with_fallback(request) + + snapshot = Telemetry.snapshot() + assert snapshot.dashboards.renderer_usage.fallback >= 0 + + assert {:ok, info} = Registry.renderer_info(:html) + + expected_fallback_count = + if info.mode == :adapter_fallback do + 1 + else + 0 + end + + assert snapshot.dashboards.renderer_usage.fallback == expected_fallback_count end test "get_fallback_renderer returns a renderer" do @@ -141,6 +167,19 @@ defmodule AshUI.Rendering.SelectorTest do assert match?({:ok, _, _}, result) end + test "get_fallback_renderer honors explicit fallback policy" do + result = + Selector.get_fallback_renderer( + fallback_renderer: :html, + allow_adapter_fallback: false, + fallback_allow_adapter_fallback: true + ) + + assert {:ok, :html, module} = result + assert {:ok, info} = Registry.renderer_info(:html) + assert module == info.module + end + test "select_for_environment selects renderer for dev environment" do assert {:ok, _, module} = Selector.select_for_environment(:dev) assert is_atom(module) @@ -161,58 +200,37 @@ defmodule AshUI.Rendering.SelectorTest do end test "detects LiveView from accepts header" do - request = %{ - headers: %{"accepts" => "text/vnd.phoenix.live-view, application/json"} - } - + request = %{headers: %{"accepts" => "text/vnd.phoenix.live-view, application/json"}} assert Selector.liveview_request?(request) end test "detects LiveView from _format=live param" do - request_map = %{ - params: %{"_format" => "live"} - } - - assert Selector.liveview_request?(request_map) + request = %{params: %{"_format" => "live"}} + assert Selector.liveview_request?(request) end test "detects LiveView from _format=liveview param" do - request_map = %{ - params: %{"_format" => "liveview"} - } - - assert Selector.liveview_request?(request_map) + request = %{params: %{"_format" => "liveview"}} + assert Selector.liveview_request?(request) end test "detects HTTP from html accept header" do - request = %{ - headers: %{"accept" => "text/html, application/xhtml+xml"} - } - + request = %{headers: %{"accept" => "text/html, application/xhtml+xml"}} assert Selector.http_request?(request) end test "detects HTTP from xhtml accept header" do - request = %{ - headers: %{"accept" => "application/xhtml+xml"} - } - + request = %{headers: %{"accept" => "application/xhtml+xml"}} assert Selector.http_request?(request) end test "handles lowercase header names in map" do - request = %{ - "headers" => %{"accepts" => "text/vnd.phoenix.live-view"} - } - + request = %{"headers" => %{"accepts" => "text/vnd.phoenix.live-view"}} assert Selector.liveview_request?(request) end test "handles x- prefix header variations" do - request = %{ - "headers" => %{"http-x-renderer" => "liveview"} - } - + request = %{"headers" => %{"http-x-renderer" => "liveview"}} assert {:ok, :liveview, _} = Selector.select_for_request(request) end end diff --git a/test/ash_ui/runtime/action_binding_test.exs b/test/ash_ui/runtime/action_binding_test.exs index 63bf92b4..1047be19 100644 --- a/test/ash_ui/runtime/action_binding_test.exs +++ b/test/ash_ui/runtime/action_binding_test.exs @@ -2,58 +2,98 @@ defmodule AshUI.Runtime.ActionBindingTest do use ExUnit.Case, async: true alias AshUI.Runtime.ActionBinding + alias AshUI.Test.RuntimeDomain + alias AshUI.Test.RuntimeFixtures + alias AshUI.Test.User + + @moduletag :conformance describe "execute_action/4" do setup do - context = %{ - user_id: "user-1", - params: %{}, - assigns: %{} - } + fixtures = RuntimeFixtures.seed!() binding = %{ id: "action-binding-test", source: %{"resource" => "User", "action" => "create"}, target: "submit-button", - binding_type: :action + binding_type: :action, + transform: %{ + "params" => %{ + "name" => {"event", "name"}, + "email" => {"event", "email"}, + "nickname" => {"static", "Created"} + } + } } - %{binding: binding, context: context} + %{binding: binding, context: RuntimeFixtures.context(fixtures)} end - test "executes action with event data", %{binding: binding, context: context} do + test "executes an Ash create action with mapped event data", %{ + binding: binding, + context: context + } do event_data = %{"name" => "John", "email" => "john@example.com"} assert {:ok, result} = ActionBinding.execute_action(binding, event_data, context) assert result.status == :ok - assert result.data != nil + assert %User{} = result.data + assert result.data.name == "John" + assert result.data.nickname == "Created" end - test "returns error for unauthorized action", %{binding: binding} do - unauthorized_context = %{user_id: nil, params: %{}, assigns: %{}} + test "returns a formatted error for unauthorized actions", %{binding: binding} do + unauthorized_context = %{ + user_id: nil, + params: %{}, + assigns: %{}, + ash_domains: [RuntimeDomain] + } - assert {:error, _reason} = ActionBinding.execute_action(binding, %{}, unauthorized_context) + assert {:error, error} = ActionBinding.execute_action(binding, %{}, unauthorized_context) + assert error.status == :error + assert error.errors == [%{"message" => "Unauthorized"}] end end describe "event_handler/2" do - test "generates LiveView event handler" do + test "executes the bound action from a LiveView-style handler" do + fixtures = RuntimeFixtures.seed!() + binding = %{ id: "handler-test", - source: %{"resource" => "User", "action" => "delete"}, - target: "delete-button", - binding_type: :action + source: %{"resource" => "User", "action" => "create"}, + target: "create-user", + binding_type: :action, + metadata: %{"success_message" => "Created"}, + transform: %{ + "params" => %{ + "name" => {"event", "name"}, + "email" => {"event", "email"} + } + } } handler = ActionBinding.event_handler(binding, "button-1") + socket = RuntimeFixtures.socket(current_user: fixtures.actor) + + assert {:noreply, updated_socket} = + handler.( + socket, + %{"name" => "Handler User", "email" => "handler@example.com"}, + %{} + ) + + assert get_in(updated_socket.assigns, [:ash_ui, :actions, "create-user", "result", :status]) == + :ok - assert is_function(handler) + assert get_in(updated_socket.assigns, [:flash, :info]) == ["Created"] end end describe "wire_handlers/2" do test "creates handler map from action bindings" do - socket = %{assigns: %{}} + socket = RuntimeFixtures.socket() bindings = [ %{ @@ -62,13 +102,6 @@ defmodule AshUI.Runtime.ActionBindingTest do target: "create-btn", binding_type: :action }, - %{ - id: "action-2", - source: %{"resource" => "Post", "action" => "delete"}, - target: "delete-btn", - binding_type: :action - }, - # Non-action binding should be excluded %{ id: "value-1", source: %{"resource" => "User", "field" => "name"}, @@ -79,8 +112,8 @@ defmodule AshUI.Runtime.ActionBindingTest do handlers = ActionBinding.wire_handlers(bindings, socket) - assert map_size(handlers) == 2 - assert Enum.all?(handlers, fn {_, handler} -> is_function(handler) end) + assert Map.keys(handlers) == ["ash_ui_action_create-btn"] + assert is_function(handlers["ash_ui_action_create-btn"]) end end end diff --git a/test/ash_ui/runtime/bidirectional_binding_test.exs b/test/ash_ui/runtime/bidirectional_binding_test.exs index aa0fd49e..fe12b76c 100644 --- a/test/ash_ui/runtime/bidirectional_binding_test.exs +++ b/test/ash_ui/runtime/bidirectional_binding_test.exs @@ -1,87 +1,112 @@ defmodule AshUI.Runtime.BidirectionalBindingTest do - use AshUI.DataCase, async: false + use ExUnit.Case, async: true + + require Ash.Query alias AshUI.Runtime.BidirectionalBinding + alias AshUI.Test.RuntimeDomain + alias AshUI.Test.RuntimeFixtures + alias AshUI.Test.User + + @moduletag :conformance - describe "read_binding/2" do + describe "read_binding/3" do test "reads binding value and updates socket assigns" do - socket = %Phoenix.LiveView.Socket{ - assigns: %{ash_ui: %{}} - } + fixtures = RuntimeFixtures.seed!() + socket = RuntimeFixtures.socket() binding = %{ id: "binding-read-test", - source: %{"resource" => "User", "field" => "name"}, + source: %{"resource" => "User", "field" => "name", "id" => fixtures.user.id}, target: "name-input", binding_type: :value } - context = %{user_id: "user-1", params: %{}, assigns: %{}} + context = RuntimeFixtures.context(fixtures) assert {:ok, updated_socket} = BidirectionalBinding.read_binding(binding, socket, context) - assert updated_socket != socket + + assert get_in(updated_socket.assigns, [:ash_ui, :bindings, "name-input", "value"]) == + "Pascal" end end describe "write_binding/4" do - test "writes user input back to Ash resource" do - socket = %Phoenix.LiveView.Socket{ - assigns: %{ash_ui: %{}} + setup do + fixtures = RuntimeFixtures.seed!() + + %{ + fixtures: fixtures, + socket: RuntimeFixtures.socket(), + context: RuntimeFixtures.context(fixtures) } + end + test "writes sanitized user input back to the Ash resource", %{ + fixtures: fixtures, + socket: socket, + context: context + } do binding = %{ id: "binding-write-test", - source: %{"resource" => "User", "field" => "name"}, + source: %{"resource" => "User", "field" => "name", "id" => fixtures.user.id}, target: "name-input", - binding_type: :value + binding_type: :value, + transform: %{"sanitize" => [%{"type" => "trim"}]} } - context = %{user_id: "user-1", params: %{}, assigns: %{}} - new_value = "Updated Name" + assert {:ok, updated_socket, result} = + BidirectionalBinding.write_binding(binding, " Updated Name ", socket, context) - assert {:ok, _socket, result} = BidirectionalBinding.write_binding(binding, new_value, socket, context) assert result.status == :ok - end + assert result.value == "Updated Name" - test "validates input before writing" do - socket = %Phoenix.LiveView.Socket{ - assigns: %{ash_ui: %{}} - } + assert get_in(updated_socket.assigns, [:ash_ui, :bindings, "name-input", "value"]) == + "Updated Name" + + query = Ash.Query.filter(User, id == ^fixtures.user.id) + assert {:ok, updated_user} = Ash.read_one(query, domain: RuntimeDomain) + assert updated_user.name == "Updated Name" + end + + test "validates input before writing", %{fixtures: fixtures, socket: socket, context: context} do binding = %{ id: "binding-validate-test", - source: %{"resource" => "User", "field" => "email"}, + source: %{"resource" => "User", "field" => "email", "id" => fixtures.user.id}, target: "email-input", binding_type: :value, transform: %{"validate" => [%{"type" => "required"}]} } - context = %{user_id: "user-1", params: %{}, assigns: %{}} + assert {:error, :required, error_socket} = + BidirectionalBinding.write_binding(binding, "", socket, context) - # Empty string should fail required validation - assert {:error, _reason, _socket} = BidirectionalBinding.write_binding(binding, "", socket, context) + assert get_in(error_socket.assigns, [:ash_ui, :bindings, "email-input", "error"]) == + :required end end describe "subscribe_binding/3" do test "subscribes to resource changes" do - socket = %Phoenix.LiveView.Socket{ - assigns: %{ash_ui: %{}} - } + fixtures = RuntimeFixtures.seed!() + socket = RuntimeFixtures.socket() binding = %{ id: "binding-subscribe-test", - source: %{"resource" => "User", "field" => "name"}, + source: %{"resource" => "User", "field" => "name", "id" => fixtures.user.id}, target: "name-input", binding_type: :value } - context = %{user_id: "user-1", params: %{}, assigns: %{}} + context = RuntimeFixtures.context(fixtures) - assert {:ok, updated_socket} = BidirectionalBinding.subscribe_binding(binding, socket, context) + assert {:ok, updated_socket} = + BidirectionalBinding.subscribe_binding(binding, socket, context) subscriptions = get_in(updated_socket.assigns, [:ash_ui, :subscriptions]) assert is_map(subscriptions) + assert map_size(subscriptions) == 1 end end end diff --git a/test/ash_ui/runtime/binding_evaluator_test.exs b/test/ash_ui/runtime/binding_evaluator_test.exs index d8cfec46..51b78d35 100644 --- a/test/ash_ui/runtime/binding_evaluator_test.exs +++ b/test/ash_ui/runtime/binding_evaluator_test.exs @@ -2,67 +2,68 @@ defmodule AshUI.Runtime.BindingEvaluatorTest do use ExUnit.Case, async: true alias AshUI.Runtime.BindingEvaluator + alias AshUI.Test.RuntimeFixtures + + @moduletag :conformance describe "evaluate/3" do setup do - context = %{ - user_id: "user-123", - params: %{"screen_id" => "screen-1"}, - assigns: %{} - } + fixtures = RuntimeFixtures.seed!() + context = RuntimeFixtures.context(fixtures) - %{context: context} + %{fixtures: fixtures, context: context} end - test "evaluates field binding successfully", %{context: context} do + test "evaluates field binding successfully", %{fixtures: fixtures, context: context} do binding = %{ - source: %{"resource" => "User", "field" => "name"}, + source: %{"resource" => "User", "field" => "name", "id" => fixtures.user.id}, target: "input-name", binding_type: :value } - assert {:ok, value} = BindingEvaluator.evaluate(binding, context) - assert is_map(value) or is_binary(value) + assert {:ok, "Pascal"} = BindingEvaluator.evaluate(binding, context) end - test "applies default transformation", %{context: context} do + test "applies default transformation", %{fixtures: fixtures, context: context} do binding = %{ - source: %{"resource" => "User", "field" => "nickname"}, + source: %{"resource" => "User", "field" => "nickname", "id" => fixtures.user.id}, target: "input-nickname", binding_type: :value, transform: %{"function" => "default", "args" => ["Anonymous"]} } - # When field is nil or empty, should return default - assert {:ok, _value} = BindingEvaluator.evaluate(binding, context) + assert {:ok, "Anonymous"} = BindingEvaluator.evaluate(binding, context) end - test "applies format transformation", %{context: context} do + test "applies format transformation", %{fixtures: fixtures, context: context} do binding = %{ - source: %{"resource" => "User", "field" => "created_at"}, + source: %{"resource" => "User", "field" => "created_at", "id" => fixtures.user.id}, target: "span-date", binding_type: :value, transform: %{"function" => "format"} } - assert {:ok, _value} = BindingEvaluator.evaluate(binding, context) + assert {:ok, formatted} = BindingEvaluator.evaluate(binding, context) + assert is_binary(formatted) + assert String.contains?(formatted, "T") end end describe "evaluate_batch/3" do test "evaluates multiple bindings" do - context = %{user_id: "user-123", params: %{}, assigns: %{}} + fixtures = RuntimeFixtures.seed!() + context = RuntimeFixtures.context(fixtures) bindings = [ %{ id: "binding-1", - source: %{"resource" => "User", "field" => "name"}, + source: %{"resource" => "User", "field" => "name", "id" => fixtures.user.id}, target: "name", binding_type: :value }, %{ id: "binding-2", - source: %{"resource" => "User", "field" => "email"}, + source: %{"resource" => "User", "field" => "email", "id" => fixtures.user.id}, target: "email", binding_type: :value } @@ -70,26 +71,31 @@ defmodule AshUI.Runtime.BindingEvaluatorTest do results = BindingEvaluator.evaluate_batch(bindings, context) - assert Map.has_key?(results, "binding-1") - assert Map.has_key?(results, "binding-2") + assert results["binding-1"] == {:ok, "Pascal"} + assert results["binding-2"] == {:ok, "pascal@example.com"} end end describe "source path resolution" do - test "resolves simple field path" do - source = %{"resource" => "User", "field" => "name"} + setup do + fixtures = RuntimeFixtures.seed!() + context = RuntimeFixtures.context(fixtures) + + %{fixtures: fixtures, context: context} + end + + test "resolves simple field path", %{fixtures: fixtures, context: context} do + source = %{"resource" => "User", "field" => "name", "id" => fixtures.user.id} binding = %{source: source, target: "test", binding_type: :value} - context = %{user_id: "user-123", params: %{}, assigns: %{}} - assert {:ok, _value} = BindingEvaluator.evaluate(binding, context) + assert {:ok, "Pascal"} = BindingEvaluator.evaluate(binding, context) end - test "resolves relationship path" do - source = %{"resource" => "User", "relationship" => "profile.name"} + test "resolves relationship path", %{fixtures: fixtures, context: context} do + source = %{"resource" => "User", "relationship" => "profile.name", "id" => fixtures.user.id} binding = %{source: source, target: "test", binding_type: :value} - context = %{user_id: "user-123", params: %{}, assigns: %{}} - assert {:ok, _value} = BindingEvaluator.evaluate(binding, context) + assert {:ok, "Primary Profile"} = BindingEvaluator.evaluate(binding, context) end end end diff --git a/test/ash_ui/runtime/list_binding_test.exs b/test/ash_ui/runtime/list_binding_test.exs index b0646ef5..9f81e111 100644 --- a/test/ash_ui/runtime/list_binding_test.exs +++ b/test/ash_ui/runtime/list_binding_test.exs @@ -2,84 +2,143 @@ defmodule AshUI.Runtime.ListBindingTest do use ExUnit.Case, async: true alias AshUI.Runtime.ListBinding + alias AshUI.Test.RuntimeFixtures + + @moduletag :conformance describe "load_collection/3" do setup do - context = %{user_id: "user-1", params: %{}, assigns: %{}} + fixtures = RuntimeFixtures.seed!() binding = %{ id: "list-binding-test", - source: %{"resource" => "Post", "relationship" => "comments"}, + source: %{ + "resource" => "Post", + "relationship" => "comments", + "id" => fixtures.post.id + }, target: "comments-list", binding_type: :list } - %{binding: binding, context: context} + %{binding: binding, context: RuntimeFixtures.context(fixtures), fixtures: fixtures} end test "loads collection with pagination", %{binding: binding, context: context} do - assert {:ok, result} = ListBinding.load_collection(binding, context, page: 1, page_size: 20) + assert {:ok, result} = ListBinding.load_collection(binding, context, page: 1, page_size: 1) - assert is_list(result.items) - assert result.total > 0 + assert length(result.items) == 1 + assert result.total == 2 assert result.page == 1 - assert result.page_size == 20 + assert result.page_size == 1 + assert result.has_next == true + assert result.has_prev == false end - test "handles empty collections", %{binding: binding, context: context} do - assert {:ok, result} = ListBinding.load_collection(binding, context, page: 999, page_size: 20) + test "handles empty pages past the end of the collection", %{ + binding: binding, + context: context + } do + assert {:ok, result} = ListBinding.load_collection(binding, context, page: 3, page_size: 2) assert result.items == [] + assert result.total == 2 assert result.has_next == false end end describe "handle_collection_change/5" do setup do - socket = %Phoenix.LiveView.Socket{ - assigns: %{ash_ui: %{}} - } + fixtures = RuntimeFixtures.seed!() + + socket = + RuntimeFixtures.socket(%{ + ash_ui: %{ + lists: %{ + "comments-list" => %{ + "items" => fixtures.comments, + "total" => length(fixtures.comments) + } + } + } + }) binding = %{ id: "list-change-test", - source: %{"resource" => "Post", "relationship" => "comments"}, + source: %{"resource" => "Post", "relationship" => "comments", "id" => fixtures.post.id}, target: "comments-list", binding_type: :list } - context = %{user_id: "user-1", params: %{}, assigns: %{}} - - %{binding: binding, context: context, socket: socket} + %{ + binding: binding, + context: RuntimeFixtures.context(fixtures), + socket: socket, + fixtures: fixtures + } end - test "handles insert changes", %{binding: binding, context: context, socket: socket} do + test "handles insert changes", %{binding: binding, socket: socket, context: context} do change_data = %{"id" => "comment-123", "content" => "New comment"} - assert {:ok, updated_socket, should_update} = - ListBinding.handle_collection_change(binding, :insert, change_data, socket, context) - - assert updated_socket.assigns != %{} - assert should_update == true + assert {:ok, updated_socket, true} = + ListBinding.handle_collection_change( + binding, + :insert, + change_data, + socket, + context + ) + + assert get_in(updated_socket.assigns, [:ash_ui, :list_changes, "comments-list"]) == [ + {:insert, change_data} + ] end - test "handles update changes", %{binding: binding, context: context, socket: socket} do - change_data = %{"id" => "comment-123", "content" => "Updated"} - - assert {:ok, updated_socket, should_update} = - ListBinding.handle_collection_change(binding, :update, change_data, socket, context) - - assert updated_socket.assigns != %{} - assert should_update == true + test "handles update changes", %{ + binding: binding, + socket: socket, + context: context, + fixtures: fixtures + } do + first_comment = hd(fixtures.comments) + change_data = %{"id" => first_comment.id, "content" => "Updated"} + + assert {:ok, updated_socket, true} = + ListBinding.handle_collection_change( + binding, + :update, + change_data, + socket, + context + ) + + updated_items = get_in(updated_socket.assigns, [:ash_ui, :lists, "comments-list", "items"]) + assert Enum.any?(updated_items, &(&1.id == first_comment.id and &1.content == "Updated")) end - test "handles delete changes", %{binding: binding, context: context, socket: socket} do - change_data = %{"id" => "comment-123"} - - assert {:ok, updated_socket, should_update} = - ListBinding.handle_collection_change(binding, :delete, change_data, socket, context) - - assert updated_socket.assigns != %{} - assert should_update == true + test "handles delete changes", %{ + binding: binding, + socket: socket, + context: context, + fixtures: fixtures + } do + first_comment = hd(fixtures.comments) + change_data = %{"id" => first_comment.id} + + assert {:ok, updated_socket, true} = + ListBinding.handle_collection_change( + binding, + :delete, + change_data, + socket, + context + ) + + updated_items = get_in(updated_socket.assigns, [:ash_ui, :lists, "comments-list", "items"]) + + refute Enum.any?(updated_items, &(&1.id == first_comment.id)) + assert get_in(updated_socket.assigns, [:ash_ui, :lists, "comments-list", "total"]) == 1 end end end diff --git a/test/ash_ui/telemetry_test.exs b/test/ash_ui/telemetry_test.exs index 21714772..c590edb1 100644 --- a/test/ash_ui/telemetry_test.exs +++ b/test/ash_ui/telemetry_test.exs @@ -7,6 +7,8 @@ defmodule AshUI.TelemetryTest do alias AshUI.Runtime.BindingEvaluator alias AshUI.Telemetry + @moduletag :conformance + setup do Telemetry.reset_metrics() :ok @@ -156,4 +158,68 @@ defmodule AshUI.TelemetryTest do assert definition["panels"] != [] end) end + + test "propagates trace and span metadata through emitted events" do + handler_id = "screen-mount-span-#{System.unique_integer([:positive])}" + + :telemetry.attach( + handler_id, + [:ash_ui, :screen, :mount], + fn _, _measurements, metadata, _ -> + send(self(), {:screen_mount, metadata}) + end, + :ok + ) + + on_exit(fn -> :telemetry.detach(handler_id) end) + + Telemetry.emit( + :screen, + :mount, + %{count: 1}, + %{ + trace_id: "trace-1", + span_id: "span-1", + parent_span_id: "parent-1", + screen_id: "screen-123" + } + ) + + assert_receive {:screen_mount, metadata} + assert metadata.trace_id == "trace-1" + assert metadata.span_id == "span-1" + assert metadata.parent_span_id == "parent-1" + assert metadata.screen_id == "screen-123" + end + + test "redacts sensitive metadata before telemetry handlers receive it" do + handler_id = "screen-mount-redaction-#{System.unique_integer([:positive])}" + + :telemetry.attach( + handler_id, + [:ash_ui, :screen, :mount], + fn _, _measurements, metadata, _ -> + send(self(), {:screen_mount_redacted, metadata}) + end, + :ok + ) + + on_exit(fn -> :telemetry.detach(handler_id) end) + + Telemetry.emit( + :screen, + :mount, + %{count: 1}, + %{ + user_id: "user-1", + email: "pascal@example.com", + token: "secret-token" + } + ) + + assert_receive {:screen_mount_redacted, metadata} + assert metadata.user_id == "user-1" + refute Map.has_key?(metadata, :email) + refute Map.has_key?(metadata, :token) + end end diff --git a/test/support/runtime_test_resources.ex b/test/support/runtime_test_resources.ex new file mode 100644 index 00000000..74622ef7 --- /dev/null +++ b/test/support/runtime_test_resources.ex @@ -0,0 +1,254 @@ +defmodule AshUI.Test.RuntimeDomain do + @moduledoc false + + use Ash.Domain, validate_config_inclusion?: false + + resources do + resource AshUI.Test.Profile + resource AshUI.Test.User + resource AshUI.Test.Post + resource AshUI.Test.Comment + end +end + +defmodule AshUI.Test.Profile do + @moduledoc false + + use Ash.Resource, + domain: AshUI.Test.RuntimeDomain, + data_layer: Ash.DataLayer.Ets + + ets do + private? true + end + + attributes do + uuid_primary_key :id + attribute :name, :string, allow_nil?: false, public?: true + end + + actions do + defaults [:read, :destroy] + + create :create do + primary? true + accept [:name] + end + + update :update do + primary? true + accept [:name] + end + end +end + +defmodule AshUI.Test.User do + @moduledoc false + + use Ash.Resource, + domain: AshUI.Test.RuntimeDomain, + data_layer: Ash.DataLayer.Ets + + ets do + private? true + end + + attributes do + uuid_primary_key :id + attribute :name, :string, allow_nil?: false, public?: true + attribute :email, :string, allow_nil?: false, public?: true + attribute :nickname, :string, public?: true + create_timestamp :created_at + update_timestamp :updated_at + end + + relationships do + belongs_to :profile, AshUI.Test.Profile do + attribute_type :uuid + allow_nil? true + end + end + + actions do + defaults [:read, :destroy] + + create :create do + primary? true + accept [:name, :email, :nickname, :profile_id] + end + + update :update do + primary? true + accept [:name, :email, :nickname, :profile_id] + end + end +end + +defmodule AshUI.Test.Post do + @moduledoc false + + use Ash.Resource, + domain: AshUI.Test.RuntimeDomain, + data_layer: Ash.DataLayer.Ets + + ets do + private? true + end + + attributes do + uuid_primary_key :id + attribute :title, :string, allow_nil?: false, public?: true + end + + relationships do + has_many :comments, AshUI.Test.Comment do + destination_attribute :post_id + end + end + + actions do + defaults [:read, :destroy] + + create :create do + primary? true + accept [:title] + end + + update :update do + primary? true + accept [:title] + end + end +end + +defmodule AshUI.Test.Comment do + @moduledoc false + + use Ash.Resource, + domain: AshUI.Test.RuntimeDomain, + data_layer: Ash.DataLayer.Ets + + ets do + private? true + end + + attributes do + uuid_primary_key :id + attribute :content, :string, allow_nil?: false, public?: true + end + + relationships do + belongs_to :post, AshUI.Test.Post do + attribute_type :uuid + allow_nil? false + end + end + + actions do + defaults [:read, :destroy] + + create :create do + primary? true + accept [:content, :post_id] + end + + update :update do + primary? true + accept [:content] + end + end +end + +defmodule AshUI.Test.RuntimeFixtures do + @moduledoc false + + alias AshUI.Test.Comment + alias AshUI.Test.Post + alias AshUI.Test.Profile + alias AshUI.Test.RuntimeDomain + alias AshUI.Test.User + + def seed! do + {:ok, profile} = + Ash.create(Profile, %{name: "Primary Profile"}, domain: RuntimeDomain) + + {:ok, user} = + Ash.create( + User, + %{ + name: "Pascal", + email: "pascal@example.com", + nickname: nil, + profile_id: profile.id + }, + domain: RuntimeDomain + ) + + {:ok, other_user} = + Ash.create( + User, + %{ + name: "Secondary", + email: "secondary@example.com", + nickname: "Second", + profile_id: profile.id + }, + domain: RuntimeDomain + ) + + {:ok, post} = + Ash.create(Post, %{title: "Release Notes"}, domain: RuntimeDomain) + + {:ok, first_comment} = + Ash.create( + Comment, + %{content: "First comment", post_id: post.id}, + domain: RuntimeDomain + ) + + {:ok, second_comment} = + Ash.create( + Comment, + %{content: "Second comment", post_id: post.id}, + domain: RuntimeDomain + ) + + %{ + actor: %{id: "actor-1", role: :admin}, + profile: profile, + user: user, + other_user: other_user, + post: post, + comments: [first_comment, second_comment] + } + end + + def context(fixtures, extra \\ %{}) do + Map.merge( + %{ + user_id: fixtures.actor.id, + actor: fixtures.actor, + params: %{}, + assigns: %{}, + ash_domains: [RuntimeDomain] + }, + extra + ) + end + + def socket(assigns \\ %{}) do + assigns = + case assigns do + assigns when is_list(assigns) -> Enum.into(assigns, %{}) + assigns -> assigns + end + + %Phoenix.LiveView.Socket{ + assigns: + assigns + |> Map.put_new(:ash_ui, %{}) + |> Map.put_new(:ash_ui_domains, [RuntimeDomain]) + |> Map.put_new(:__changed__, %{}) + } + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs index 3db8f8c3..9c4ad282 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -2,7 +2,7 @@ ExUnit.start() ExUnit.configure(exclude: [skip: true]) # Start the application for tests -Application.put_env(:ash_ui, :ash_domains, [AshUI.Domain]) +Application.put_env(:ash_ui, :ash_domains, [AshUI.Domain, AshUI.Test.RuntimeDomain]) # Ecto migrations are handled by AshPostgres # Ensure the Repo is started for tests