From b7dd0f4baf2931bd138e4b395a1b55e2ff3a777b Mon Sep 17 00:00:00 2001 From: Pascal Charbonneau Date: Fri, 20 Mar 2026 08:43:48 -0600 Subject: [PATCH] Harden release readiness and warning hygiene --- README.md | 2 +- .../basic_dashboard/lib/basic_dashboard.ex | 2 +- guides/user/UG-0001-getting-started.md | 4 +- guides/user/UG-0002-resources.md | 2 +- guides/user/UG-0003-data-binding.md | 6 +- guides/user/UG-0005-migration-v0-to-v1.md | 2 +- lib/ash_ui/authorization/binding_policy.ex | 30 +--- lib/ash_ui/authorization/element_policy.ex | 26 ---- lib/ash_ui/authorization/error.ex | 4 +- lib/ash_ui/authorization/policies.ex | 5 +- lib/ash_ui/authorization/policy_dsl.ex | 2 +- lib/ash_ui/authorization/runtime.ex | 9 +- lib/ash_ui/binding/actions.ex | 4 +- lib/ash_ui/compiler/extensions.ex | 36 ++--- lib/ash_ui/compiler/incremental.ex | 28 ++-- lib/ash_ui/data.ex | 98 +++++++++++++ lib/ash_ui/domain.ex | 80 ----------- lib/ash_ui/dsl/storage.ex | 52 ++++--- lib/ash_ui/liveview/error_handler.ex | 59 +++++--- lib/ash_ui/liveview/event_handler.ex | 24 ++-- lib/ash_ui/liveview/hooks.ex | 6 +- lib/ash_ui/liveview/lifecycle.ex | 1 - lib/ash_ui/liveview/liveview_integration.ex | 11 +- lib/ash_ui/liveview/update_integration.ex | 53 +++++-- lib/ash_ui/rendering/conversion_error.ex | 6 + lib/ash_ui/rendering/desktop_ui_adapter.ex | 30 ++-- lib/ash_ui/rendering/live_ui_adapter.ex | 12 +- lib/ash_ui/rendering/selector.ex | 14 +- lib/ash_ui/rendering/validation.ex | 2 +- lib/ash_ui/rendering/web_ui_adapter.ex | 12 +- lib/ash_ui/runtime/action_binding.ex | 25 ++-- lib/ash_ui/runtime/bidirectional_binding.ex | 38 +++-- lib/ash_ui/runtime/binding_evaluator.ex | 52 ++++--- lib/ash_ui/runtime/list_binding.ex | 135 +++++++++++++----- lib/ash_ui/signal/cloud_events.ex | 17 +-- lib/ash_ui/signal/struct.ex | 31 +++- .../phase_5_integration_test.exs | 16 +-- test/ash_ui/authorization/policies_test.exs | 2 - .../authorization/resource_policies_test.exs | 2 +- test/ash_ui/authorization/runtime_test.exs | 8 +- test/ash_ui/compiler/extensions_test.exs | 8 +- test/ash_ui/compiler/incremental_test.exs | 22 +-- .../compiler/phase_6_integration_test.exs | 50 +++---- test/ash_ui/compiler_test.exs | 27 ++-- test/ash_ui/dsl_integration_test.exs | 20 +-- test/ash_ui/liveview/error_handler_test.exs | 7 +- test/ash_ui/liveview/event_handler_test.exs | 51 ++++--- .../liveview/liveview_integration_test.exs | 6 +- .../liveview/phase_4_integration_test.exs | 46 +++--- .../liveview/update_integration_test.exs | 31 ++-- test/ash_ui/phase_8_integration_test.exs | 2 +- test/ash_ui/relationship_integration_test.exs | 28 ++-- test/ash_ui/resources/binding_test.exs | 26 ++-- test/ash_ui/resources/element_test.exs | 22 +-- test/ash_ui/resources/screen_test.exs | 22 ++- test/ash_ui/runtime/action_binding_test.exs | 8 +- test/ash_ui/runtime/list_binding_test.exs | 23 +-- test/support/data_case.ex | 2 +- 58 files changed, 743 insertions(+), 606 deletions(-) create mode 100644 lib/ash_ui/data.ex diff --git a/README.md b/README.md index 71829e47..4eb1f8b0 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ Create a screen record: ```elixir alias AshUI.DSL.Builder -alias AshUI.Domain +alias AshUI.Data, as: Domain alias AshUI.Resources.Screen {:ok, _screen} = diff --git a/examples/basic_dashboard/lib/basic_dashboard.ex b/examples/basic_dashboard/lib/basic_dashboard.ex index ec292b22..445d0415 100644 --- a/examples/basic_dashboard/lib/basic_dashboard.ex +++ b/examples/basic_dashboard/lib/basic_dashboard.ex @@ -4,7 +4,7 @@ defmodule BasicDashboard do """ alias AshUI.DSL.Builder - alias AshUI.Domain + alias AshUI.Data, as: Domain alias AshUI.Resources.Binding alias AshUI.Resources.Element alias AshUI.Resources.Screen diff --git a/guides/user/UG-0001-getting-started.md b/guides/user/UG-0001-getting-started.md index a6ae1c65..bc61e140 100644 --- a/guides/user/UG-0001-getting-started.md +++ b/guides/user/UG-0001-getting-started.md @@ -82,7 +82,7 @@ For a simple screen, the lowest-friction path is to create a `Screen` record wit ```elixir alias AshUI.DSL.Builder -alias AshUI.Domain +alias AshUI.Data, as: Domain alias AshUI.Resources.Screen dashboard_dsl = @@ -163,7 +163,7 @@ alias AshUI.Rendering.LiveUIAdapter Bindings are separate records. They connect a UI target such as `"value"` or `"submit"` to a source map that identifies a resource field or action. ```elixir -alias AshUI.Domain +alias AshUI.Data, as: Domain alias AshUI.Resources.Binding alias AshUI.Resources.Element diff --git a/guides/user/UG-0002-resources.md b/guides/user/UG-0002-resources.md index e6017e37..ae3ad7cd 100644 --- a/guides/user/UG-0002-resources.md +++ b/guides/user/UG-0002-resources.md @@ -53,7 +53,7 @@ Important fields: Create a screen: ```elixir -alias AshUI.Domain +alias AshUI.Data, as: Domain alias AshUI.Resources.Screen {:ok, screen} = diff --git a/guides/user/UG-0003-data-binding.md b/guides/user/UG-0003-data-binding.md index b0d413da..899ac1c9 100644 --- a/guides/user/UG-0003-data-binding.md +++ b/guides/user/UG-0003-data-binding.md @@ -62,7 +62,7 @@ Use `:value` when a field should be read into UI state and potentially written b ```elixir {:ok, _binding} = - AshUI.Domain.create(AshUI.Resources.Binding, + AshUI.Data.create(AshUI.Resources.Binding, attrs: %{ screen_id: screen.id, element_id: name_input.id, @@ -90,7 +90,7 @@ Use `:list` when the element expects a collection. ```elixir {:ok, _binding} = - AshUI.Domain.create(AshUI.Resources.Binding, + AshUI.Data.create(AshUI.Resources.Binding, attrs: %{ screen_id: screen.id, element_id: audit_list.id, @@ -109,7 +109,7 @@ Use `:action` when the UI should trigger an Ash-side operation. ```elixir {:ok, _binding} = - AshUI.Domain.create(AshUI.Resources.Binding, + AshUI.Data.create(AshUI.Resources.Binding, attrs: %{ screen_id: screen.id, element_id: save_button.id, diff --git a/guides/user/UG-0005-migration-v0-to-v1.md b/guides/user/UG-0005-migration-v0-to-v1.md index 8b77a503..50b36626 100644 --- a/guides/user/UG-0005-migration-v0-to-v1.md +++ b/guides/user/UG-0005-migration-v0-to-v1.md @@ -51,7 +51,7 @@ If you previously modeled screens as custom resources in your app, migrate the u ```elixir alias AshUI.DSL.Builder -alias AshUI.Domain +alias AshUI.Data, as: Domain alias AshUI.Resources.Screen {:ok, _screen} = diff --git a/lib/ash_ui/authorization/binding_policy.ex b/lib/ash_ui/authorization/binding_policy.ex index 03505691..1833df4b 100644 --- a/lib/ash_ui/authorization/binding_policy.ex +++ b/lib/ash_ui/authorization/binding_policy.ex @@ -82,7 +82,7 @@ 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 @@ -92,9 +92,6 @@ defmodule AshUI.Authorization.BindingPolicy do # 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 @@ -102,28 +99,17 @@ defmodule AshUI.Authorization.BindingPolicy do # Private functions - defp can_access_binding?(user, binding) do - # In production, would check parent screen access - Policies.user_active(user) - end - - defp screen_owned?(user, binding) do - # In production, would check parent screen ownership - true - end - - defp has_data_access?(binding, user) do + 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_access_field(binding.source, 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 @@ -133,16 +119,6 @@ defmodule AshUI.Authorization.BindingPolicy do 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 diff --git a/lib/ash_ui/authorization/element_policy.ex b/lib/ash_ui/authorization/element_policy.ex index 7a6880c0..e70a9156 100644 --- a/lib/ash_ui/authorization/element_policy.ex +++ b/lib/ash_ui/authorization/element_policy.ex @@ -36,9 +36,6 @@ defmodule AshUI.Authorization.ElementPolicy do # 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 @@ -60,9 +57,6 @@ defmodule AshUI.Authorization.ElementPolicy do # Check if element is explicitly read-only Map.get(element, :read_only, false) -> false - # Must own parent screen - not screen_owned?(user, element) -> false - # Default editable true -> true end @@ -70,26 +64,6 @@ defmodule AshUI.Authorization.ElementPolicy do # Private functions - defp screen_accessible?(user, element) do - # In production, would check if user can access parent screen - true - end - - defp screen_owned?(user, element) do - # In production, would check if user owns parent screen - true - end - - defp element_visible?(element) do - # Check if element has visibility condition - case Map.get(element, :visible_when) do - nil -> true - condition when is_function(condition, 0) -> condition.() - condition when is_boolean(condition) -> condition - _ -> true - end - end - defp meets_visibility_condition?(element, user) do case Map.get(element, :visible_when) do nil -> true diff --git a/lib/ash_ui/authorization/error.ex b/lib/ash_ui/authorization/error.ex index ff21a38d..7dcc7979 100644 --- a/lib/ash_ui/authorization/error.ex +++ b/lib/ash_ui/authorization/error.ex @@ -28,7 +28,7 @@ defmodule AshUI.AuthorizationError do @doc """ Returns the human-readable message for the authorization error. """ - def message(%__MODULE__{reason: reason} = error) do + def message(%__MODULE__{} = error) do format_message(error) end @@ -116,7 +116,7 @@ defmodule AshUI.AuthorizationError do AuthorizationError.format_message(error) """ @spec format_message(t()) :: String.t() - def format_message(%__MODULE__{reason: :unauthenticated} = error) do + def format_message(%__MODULE__{reason: :unauthenticated}) do "You must be logged in to access this resource" end diff --git a/lib/ash_ui/authorization/policies.ex b/lib/ash_ui/authorization/policies.ex index 125c1b4c..5c74fbca 100644 --- a/lib/ash_ui/authorization/policies.ex +++ b/lib/ash_ui/authorization/policies.ex @@ -187,6 +187,7 @@ defmodule AshUI.Authorization.Policies do Application.get_env(:ash_ui, :env, :dev) end - defp can_access_resource?(nil, _action), do: false - defp can_access_resource?(_resource, _action), do: true + 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 end diff --git a/lib/ash_ui/authorization/policy_dsl.ex b/lib/ash_ui/authorization/policy_dsl.ex index 57592332..0bbf91cd 100644 --- a/lib/ash_ui/authorization/policy_dsl.ex +++ b/lib/ash_ui/authorization/policy_dsl.ex @@ -32,7 +32,7 @@ defmodule AshUI.Authorization.PolicyDSL do end end - def visible_if(user, condition) when is_boolean(condition) do + def visible_if(_user, condition) when is_boolean(condition) do condition end diff --git a/lib/ash_ui/authorization/runtime.ex b/lib/ash_ui/authorization/runtime.ex index df107183..eae90cb1 100644 --- a/lib/ash_ui/authorization/runtime.ex +++ b/lib/ash_ui/authorization/runtime.ex @@ -10,7 +10,6 @@ defmodule AshUI.Authorization.Runtime do alias AshUI.Authorization.Policies alias AshUI.Authorization.ScreenPolicy - alias AshUI.Authorization.ElementPolicy alias AshUI.Authorization.BindingPolicy alias AshUI.Telemetry @@ -142,6 +141,7 @@ defmodule AshUI.Authorization.Runtime do with :ok <- emit_auth_telemetry(:read_attempt, context), :ok <- check_user_present(user), + :ok <- check_user_active(user), :ok <- check_data_source_accessible(user, binding), :ok <- check_policy(user, binding, :read) do emit_auth_telemetry(:read_success, context) @@ -175,6 +175,7 @@ defmodule AshUI.Authorization.Runtime do with :ok <- emit_auth_telemetry(:write_attempt, context), :ok <- check_user_present(user), + :ok <- check_user_active(user), :ok <- check_data_source_writable(user, binding), :ok <- check_policy(user, binding, :update) do emit_auth_telemetry(:write_success, context) @@ -290,7 +291,7 @@ defmodule AshUI.Authorization.Runtime do Runtime.invalidate_resource_cache(screen) """ @spec invalidate_resource_cache(term()) :: :ok - def invalidate_resource_cache(resource) do + def invalidate_resource_cache(_resource) do # In production, would selectively invalidate by resource :ets.delete_all_objects(:ash_ui_auth_cache) :ok @@ -390,7 +391,7 @@ defmodule AshUI.Authorization.Runtime do end end - defp check_action_allowed(user, action, params) do + defp check_action_allowed(user, _action, _params) do # Check if user role allows this action if Policies.user_role(user, :admin) do :ok @@ -426,7 +427,7 @@ defmodule AshUI.Authorization.Runtime do end end - defp check_policy(user, resource, action) do + defp check_policy(_user, _resource, _action) do # In production, would use Ash.Policy.Authorizer :ok end diff --git a/lib/ash_ui/binding/actions.ex b/lib/ash_ui/binding/actions.ex index 757f2470..bf86c0f2 100644 --- a/lib/ash_ui/binding/actions.ex +++ b/lib/ash_ui/binding/actions.ex @@ -18,10 +18,10 @@ defmodule AshUI.Binding.Actions do end end - defp resolve_source(source, context) do + defp resolve_source(source, _context) do # Parse source path like "MyApp.Accounts.User.name" # and resolve to actual value from context - parts = String.split(source, ".") + _parts = String.split(source, ".") # TODO: Implement actual source resolution {:ok, "Resolved Value"} end diff --git a/lib/ash_ui/compiler/extensions.ex b/lib/ash_ui/compiler/extensions.ex index f6b3efbe..02986ed5 100644 --- a/lib/ash_ui/compiler/extensions.ex +++ b/lib/ash_ui/compiler/extensions.ex @@ -303,13 +303,13 @@ defmodule AshUI.Compiler.Extensions do defp store_widget(type, definition) do ensure_tables() - :ets.insert(:ash_ui_widgets, {type, definition}) + :ets.insert(:ash_ui_widgets, {type, Map.put_new(definition, :type, type)}) :ok end defp store_layout(type, definition) do ensure_tables() - :ets.insert(:ash_ui_layouts, {type, definition}) + :ets.insert(:ash_ui_layouts, {type, Map.put_new(definition, :type, type)}) :ok end @@ -341,7 +341,7 @@ defmodule AshUI.Compiler.Extensions do @spec available_widget_types() :: [String.t()] def available_widget_types do built_in = ["text", "button", "input", "checkbox", "select", "image", "spacer"] - custom = Enum.map(registered_widgets(), fn x -> x.type end) + custom = Enum.map(registered_widgets(), &Map.get(&1, :type)) built_in ++ custom end @@ -356,7 +356,7 @@ defmodule AshUI.Compiler.Extensions do @spec available_layout_types() :: [String.t()] def available_layout_types do built_in = ["row", "column", "grid", "stack", "fragment", "container"] - custom = Enum.map(registered_layouts(), fn x -> x.type end) + custom = Enum.map(registered_layouts(), &Map.get(&1, :type)) built_in ++ custom end @@ -389,8 +389,8 @@ defmodule AshUI.Compiler.Extensions do end end - defp validate_widget_props_spec(definition, errors) do - props = definition.props || [] + defp validate_widget_props_spec(errors, definition) do + props = Map.get(definition, :props, []) Enum.reduce(props, errors, fn prop_spec, acc -> case validate_prop_spec(prop_spec) do @@ -411,16 +411,16 @@ defmodule AshUI.Compiler.Extensions do end end - defp validate_widget_module(definition, errors) do - if Code.ensure_loaded?(definition.module) do + defp validate_widget_module(errors, definition) do + if is_atom(Map.get(definition, :module)) do errors else - ["Module #{inspect(definition.module)} not loaded" | errors] + ["Widget must declare a module" | errors] end end - defp validate_widget_compile(definition, errors) do - if is_function(definition.compile) do + defp validate_widget_compile(errors, definition) do + if is_function(Map.get(definition, :compile)) do errors else ["Widget must have a compile function" | errors] @@ -448,8 +448,8 @@ defmodule AshUI.Compiler.Extensions do end end - defp validate_layout_props_spec(definition, errors) do - props = definition.props || [] + defp validate_layout_props_spec(errors, definition) do + props = Map.get(definition, :props, []) Enum.reduce(props, errors, fn prop_spec, acc -> case validate_prop_spec(prop_spec) do @@ -459,16 +459,16 @@ defmodule AshUI.Compiler.Extensions do end) end - defp validate_layout_module(definition, errors) do - if Code.ensure_loaded?(definition.module) do + defp validate_layout_module(errors, definition) do + if is_atom(Map.get(definition, :module)) do errors else - ["Module #{inspect(definition.module)} not loaded" | errors] + ["Layout must declare a module" | errors] end end - defp validate_layout_compile(definition, errors) do - if is_function(definition.compile) do + defp validate_layout_compile(errors, definition) do + if is_function(Map.get(definition, :compile)) do errors else ["Layout must have a compile function" | errors] diff --git a/lib/ash_ui/compiler/incremental.ex b/lib/ash_ui/compiler/incremental.ex index 638ed65a..b87d18d2 100644 --- a/lib/ash_ui/compiler/incremental.ex +++ b/lib/ash_ui/compiler/incremental.ex @@ -7,8 +7,11 @@ defmodule AshUI.Compiler.Incremental do """ 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 @@ -40,7 +43,10 @@ defmodule AshUI.Compiler.Incremental do with {:ok, elements} <- load_screen_elements(screen), graph <- build_element_dependencies(graph, screen, elements), graph <- build_binding_dependencies(graph, elements) do - detect_circular_dependencies(graph) + case detect_circular_dependencies(graph) do + :ok -> {:ok, graph} + {:error, cycles} -> {:error, cycles} + end end end @@ -200,17 +206,20 @@ defmodule AshUI.Compiler.Incremental do actor = Keyword.get(opts, :actor) tenant = Keyword.get(opts, :tenant) - case Ash.get(Screen, screen_id, actor: actor, tenant: tenant) do + case Ash.get(Screen, screen_id, actor: actor, tenant: tenant, domain: Domain) do {:ok, screen} -> {:ok, screen} {:error, reason} -> {:error, {:screen_not_found, reason}} end end defp load_screen_elements(%Screen{id: screen_id}) do - case Ash.read(Element, filter: [screen_id: screen_id], sort: [position: :asc]) do - {:ok, elements} -> {:ok, elements} - {:error, _} -> {:ok, []} - end + elements = + Element + |> where([element], element.screen_id == ^screen_id) + |> order_by([element], asc: element.position) + |> Repo.all() + + {:ok, elements} end defp build_element_dependencies(graph, screen, elements) do @@ -250,10 +259,9 @@ defmodule AshUI.Compiler.Incremental do end defp get_element_bindings(%Element{id: element_id}) do - case Ash.read(Binding, filter: [element_id: element_id]) do - {:ok, bindings} -> bindings - {:error, _} -> [] - end + Binding + |> where([binding], binding.element_id == ^element_id) + |> Repo.all() end defp find_cycles(graph) do diff --git a/lib/ash_ui/data.ex b/lib/ash_ui/data.ex new file mode 100644 index 00000000..f769e7d8 --- /dev/null +++ b/lib/ash_ui/data.ex @@ -0,0 +1,98 @@ +defmodule AshUI.Data do + @moduledoc """ + Thin CRUD helpers that route Ash operations through `AshUI.Domain`. + + This keeps convenience data access separate from the domain definition itself, + which avoids conflicts with functions generated by `use Ash.Domain`. + """ + + require Ash.Query + + alias AshUI.Domain + + @doc """ + Creates a record through `AshUI.Domain`, accepting either standard Ash options + or an `:attrs` key with the input attributes. + """ + def create(resource, opts \\ []) when is_atom(resource) and is_list(opts) do + case Keyword.fetch(opts, :attrs) do + {:ok, attrs} -> + opts = opts |> Keyword.delete(:attrs) |> Keyword.put(:domain, Domain) + Ash.create(resource, attrs, opts) + + :error -> + Ash.create(resource, Keyword.put(opts, :domain, Domain)) + end + end + + @doc """ + Updates a record through `AshUI.Domain`, accepting either standard Ash options + or an `:attrs` key with the update attributes. + """ + def update(record, opts \\ []) when is_list(opts) do + case Keyword.fetch(opts, :attrs) do + {:ok, attrs} -> + opts = opts |> Keyword.delete(:attrs) |> Keyword.put(:domain, Domain) + Ash.update(record, attrs, opts) + + :error -> + Ash.update(record, Keyword.put(opts, :domain, Domain)) + end + end + + @doc """ + Destroys a record through `AshUI.Domain`. + """ + def destroy(record, opts \\ []) when is_list(opts) do + Ash.destroy(record, Keyword.put(opts, :domain, Domain)) + end + + @doc """ + Reads a collection of records from the given resource, optionally applying a + simple keyword filter before delegating to `Ash.read/2`. + """ + def read(resource, opts \\ []) when is_atom(resource) and is_list(opts) do + {filter, opts} = Keyword.pop(opts, :filter) + query = apply_filter(resource, filter) + + Ash.read(query, Keyword.put(opts, :domain, Domain)) + end + + @doc """ + Reads a collection of records and raises on failure. + """ + def read!(resource, opts \\ []) when is_atom(resource) and is_list(opts) do + {filter, opts} = Keyword.pop(opts, :filter) + query = apply_filter(resource, filter) + + Ash.read!(query, Keyword.put(opts, :domain, Domain)) + end + + @doc """ + Reads a single record from the given resource, optionally applying a filter. + """ + def read_one(resource, opts \\ []) when is_atom(resource) and is_list(opts) do + {filter, opts} = Keyword.pop(opts, :filter) + query = apply_filter(resource, filter) + + Ash.read_one(query, Keyword.put(opts, :domain, Domain)) + end + + @doc """ + Reads a single record and raises on failure. + """ + def read_one!(resource, opts \\ []) when is_atom(resource) and is_list(opts) do + {filter, opts} = Keyword.pop(opts, :filter) + query = apply_filter(resource, filter) + + Ash.read_one!(query, Keyword.put(opts, :domain, Domain)) + end + + defp apply_filter(resource, nil), do: resource + + defp apply_filter(resource, filter) do + resource + |> Ash.Query.new() + |> Ash.Query.filter(^filter) + end +end diff --git a/lib/ash_ui/domain.ex b/lib/ash_ui/domain.ex index d3d927fe..449953a8 100644 --- a/lib/ash_ui/domain.ex +++ b/lib/ash_ui/domain.ex @@ -5,90 +5,10 @@ defmodule AshUI.Domain do This domain defines the authorization and resource boundaries for the Ash UI system. """ use Ash.Domain - require Ash.Query resources do resource AshUI.Resources.Screen resource AshUI.Resources.Element resource AshUI.Resources.Binding end - - @doc """ - Creates a record through `AshUI.Domain`, accepting either standard Ash options - or an `:attrs` key with the input attributes. - """ - def create(resource, opts) when is_atom(resource) and is_list(opts) do - case Keyword.fetch(opts, :attrs) do - {:ok, attrs} -> - opts = opts |> Keyword.delete(:attrs) |> Keyword.put(:domain, __MODULE__) - Ash.create(resource, attrs, opts) - - :error -> - Ash.create(resource, Keyword.put(opts, :domain, __MODULE__)) - end - end - - @doc """ - Updates a record through `AshUI.Domain`, accepting either standard Ash options - or an `:attrs` key with the update attributes. - """ - def update(record, opts) when is_list(opts) do - case Keyword.fetch(opts, :attrs) do - {:ok, attrs} -> - opts = opts |> Keyword.delete(:attrs) |> Keyword.put(:domain, __MODULE__) - Ash.update(record, attrs, opts) - - :error -> - Ash.update(record, Keyword.put(opts, :domain, __MODULE__)) - end - end - - @doc """ - Reads a collection of records from the given resource, optionally applying a - simple keyword filter before delegating to `Ash.read/2`. - """ - def read(resource, opts) when is_atom(resource) and is_list(opts) do - {filter, opts} = Keyword.pop(opts, :filter) - query = apply_filter(resource, filter) - - Ash.read(query, Keyword.put(opts, :domain, __MODULE__)) - end - - @doc """ - Reads a collection of records and raises on failure. - """ - def read!(resource, opts) when is_atom(resource) and is_list(opts) do - {filter, opts} = Keyword.pop(opts, :filter) - query = apply_filter(resource, filter) - - Ash.read!(query, Keyword.put(opts, :domain, __MODULE__)) - end - - @doc """ - Reads a single record from the given resource, optionally applying a filter. - """ - def read_one(resource, opts) when is_atom(resource) and is_list(opts) do - {filter, opts} = Keyword.pop(opts, :filter) - query = apply_filter(resource, filter) - - Ash.read_one(query, Keyword.put(opts, :domain, __MODULE__)) - end - - @doc """ - Reads a single record and raises on failure. - """ - def read_one!(resource, opts) when is_atom(resource) and is_list(opts) do - {filter, opts} = Keyword.pop(opts, :filter) - query = apply_filter(resource, filter) - - Ash.read_one!(query, Keyword.put(opts, :domain, __MODULE__)) - end - - defp apply_filter(resource, nil), do: resource - - defp apply_filter(resource, filter) do - resource - |> Ash.Query.new() - |> Ash.Query.filter(^filter) - end end diff --git a/lib/ash_ui/dsl/storage.ex b/lib/ash_ui/dsl/storage.ex index 02a0fde5..d39e724c 100644 --- a/lib/ash_ui/dsl/storage.ex +++ b/lib/ash_ui/dsl/storage.ex @@ -46,15 +46,14 @@ defmodule AshUI.DSL.Storage do """ @spec default() :: unified_dsl() def default do + timestamp = DateTime.utc_now() |> DateTime.to_iso8601() + %{ type: "fragment", props: %{}, children: [], signals: [], - metadata: %{ - version: "1.0.0", - created_at: DateTime.utc_now() |> DateTime.to_iso8601() - } + metadata: normalize_metadata_keys(%{version: "1.0.0", created_at: timestamp}) } end @@ -120,10 +119,14 @@ defmodule AshUI.DSL.Storage do """ @spec widget_types(unified_dsl()) :: [String.t()] def widget_types(dsl) do - types = [dsl.type] + types = + case dsl_type(dsl) do + nil -> [] + type -> [type] + end child_types = - Enum.flat_map(dsl.children || [], fn child -> + Enum.flat_map(dsl_children(dsl), fn child -> widget_types(child) end) @@ -140,10 +143,10 @@ defmodule AshUI.DSL.Storage do """ @spec signal_references(unified_dsl()) :: [signal()] def signal_references(dsl) do - local_signals = dsl.signals || [] + local_signals = dsl_signals(dsl) child_signals = - Enum.flat_map(dsl.children || [], fn child -> + Enum.flat_map(dsl_children(dsl), fn child -> signal_references(child) end) @@ -163,7 +166,7 @@ defmodule AshUI.DSL.Storage do @spec put_metadata(unified_dsl(), map()) :: unified_dsl() def put_metadata(dsl, metadata) do Map.update(dsl, :metadata, metadata, fn existing -> - Map.merge(existing, metadata) + Map.merge(existing, normalize_metadata_keys(metadata)) end) end @@ -175,8 +178,9 @@ defmodule AshUI.DSL.Storage do metadata = AshUI.DSL.Storage.get_metadata(dsl) """ @spec get_metadata(unified_dsl()) :: map() - def get_metadata(%{metadata: metadata}), do: metadata - def get_metadata(_), do: %{} + def get_metadata(dsl) when is_map(dsl) do + Map.get(dsl, :metadata) || Map.get(dsl, "metadata") || %{} + end @doc """ Increments the DSL version. @@ -200,7 +204,7 @@ defmodule AshUI.DSL.Storage do missing = Enum.reject(required_fields, fn field -> - Map.has_key?(dsl, field) + Map.has_key?(dsl, field) or Map.has_key?(dsl, Atom.to_string(field)) end) case missing do @@ -237,14 +241,14 @@ defmodule AshUI.DSL.Storage do end defp validate_no_circular_refs(errors, dsl, path) do - current_type = Map.get(dsl, :type) + current_type = dsl_type(dsl) if current_type in path do ["Circular reference detected: #{Enum.join(path ++ [current_type], " -> ")}" | errors] else new_path = path ++ [current_type] - Enum.reduce(dsl.children || [], errors, fn child, acc -> + Enum.reduce(dsl_children(dsl), errors, fn child, acc -> validate_no_circular_refs(acc, child, new_path) end) end @@ -252,11 +256,27 @@ defmodule AshUI.DSL.Storage do defp valid_signal_structure?(signal) do is_map(signal) and - Map.has_key?(signal, :type) and - Map.has_key?(signal, :target) + (Map.has_key?(signal, :type) or Map.has_key?(signal, "type")) and + (Map.has_key?(signal, :target) or Map.has_key?(signal, "target")) end defp get_next_version do "1.0.#{System.system_time(:second)}" end + + defp dsl_type(dsl), do: Map.get(dsl, :type) || Map.get(dsl, "type") + defp dsl_children(dsl), do: Map.get(dsl, :children) || Map.get(dsl, "children") || [] + defp dsl_signals(dsl), do: Map.get(dsl, :signals) || Map.get(dsl, "signals") || [] + + defp normalize_metadata_keys(metadata) when is_map(metadata) do + Enum.reduce(metadata, metadata, fn + {:version, value}, acc -> Map.put(acc, "version", value) + {"version", value}, acc -> Map.put(acc, :version, value) + {:created_at, value}, acc -> Map.put(acc, "created_at", value) + {"created_at", value}, acc -> Map.put(acc, :created_at, value) + {:updated_at, value}, acc -> Map.put(acc, "updated_at", value) + {"updated_at", value}, acc -> Map.put(acc, :updated_at, value) + _, acc -> acc + end) + end end diff --git a/lib/ash_ui/liveview/error_handler.ex b/lib/ash_ui/liveview/error_handler.ex index 55f95499..3b933aaf 100644 --- a/lib/ash_ui/liveview/error_handler.ex +++ b/lib/ash_ui/liveview/error_handler.ex @@ -8,7 +8,6 @@ defmodule AshUI.LiveView.ErrorHandler do require Logger - alias AshUI.LiveView.Integration alias AshUI.Telemetry @type error_info :: %{ @@ -68,7 +67,7 @@ defmodule AshUI.LiveView.ErrorHandler do end """ @spec handle_binding_error(map(), term(), Phoenix.LiveView.Socket.t()) :: - {:error, term()} | term() + {:error, term(), Phoenix.LiveView.Socket.t()} def handle_binding_error(binding, reason, socket) do error_info = build_error_info(:binding, reason, socket, binding: binding) @@ -79,10 +78,10 @@ defmodule AshUI.LiveView.ErrorHandler do emit_error_telemetry(error_info) # Store error in binding state for UI to handle - socket = store_binding_error(socket, binding, error_info) + updated_socket = store_binding_error(socket, binding, error_info) # Return error placeholder value - {:error, reason} + {:error, reason, updated_socket} end @doc """ @@ -162,9 +161,9 @@ defmodule AshUI.LiveView.ErrorHandler do reason: exception, message: Exception.message(exception), timestamp: DateTime.utc_now(), - context: %{ - stacktrace: Exception.format_stacktrace(stacktrace) - } + context: + base_context(socket) + |> Map.put(:stacktrace, format_stacktrace(stacktrace)) } # Log runtime error @@ -264,7 +263,7 @@ defmodule AshUI.LiveView.ErrorHandler do def user_friendly_message(%{type: :binding, reason: reason}) do case reason do - {:not_found, resource} -> "The requested data could not be found." + {:not_found, _resource} -> "The requested data could not be found." {:unauthorized, _} -> "You don't have permission to view this data." _ -> "Unable to load some data. Please refresh the page." end @@ -322,18 +321,14 @@ defmodule AshUI.LiveView.ErrorHandler do # Private functions defp build_error_info(type, reason, socket, extra_context \\ %{}) do - base_context = %{ - screen_id: get_screen_id(socket), - user_id: get_user_id(socket), - session_id: get_session_id(socket) - } + context = Map.merge(base_context(socket), normalize_context(extra_context)) %{ type: type, reason: reason, message: format_error_message(reason), timestamp: DateTime.utc_now(), - context: Map.merge(base_context, extra_context) + context: context } end @@ -404,14 +399,17 @@ 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}, %{ error: inspect(error_info.reason), + reason: inspect(error_info.reason), resource_type: :screen, - screen_id: error_info.context.screen_id, - user_id: error_info.context.user_id, + screen_id: Map.get(context, :screen_id) || Map.get(context, "screen_id"), + user_id: Map.get(context, :user_id) || Map.get(context, "user_id"), status: :error } ) @@ -429,12 +427,13 @@ defmodule AshUI.LiveView.ErrorHandler do message = user_friendly_message(error_info) current_flashes = Map.get(socket.assigns, :flash, %{}) updated = Map.put(current_flashes, :error, message) - Phoenix.Component.assign(socket, :flash, updated) + %{socket | assigns: Map.put(socket.assigns, :flash, updated)} end defp store_binding_error(socket, binding, error_info) do binding_errors = Map.get(socket.assigns, :ash_ui_binding_errors, %{}) - updated = Map.put(binding_errors, binding.id, error_info) + binding_id = Map.get(binding, :id) || Map.get(binding, "id") || "unknown_binding" + updated = Map.put(binding_errors, binding_id, error_info) Phoenix.Component.assign(socket, :ash_ui_binding_errors, updated) end @@ -456,7 +455,7 @@ defmodule AshUI.LiveView.ErrorHandler do {:ok, result} -> {:ok, result} - {:error, _reason} = error -> + {:error, _reason} -> 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) @@ -479,4 +478,26 @@ 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 217be408..ff4eeaaa 100644 --- a/lib/ash_ui/liveview/event_handler.ex +++ b/lib/ash_ui/liveview/event_handler.ex @@ -8,7 +8,6 @@ defmodule AshUI.LiveView.EventHandler do require Logger - alias AshUI.Resources.Binding alias AshUI.Runtime.ActionBinding alias AshUI.Runtime.BidirectionalBinding @@ -96,9 +95,10 @@ 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, binding} <- find_action_binding(action_id, socket), - context <- build_event_context(socket), + with :ok <- authorize_action_context(context), + {:ok, binding} <- find_action_binding(action_id, socket), {:ok, result} <- execute_action(binding, event_data, socket, context), socket <- handle_action_result(result, socket) do {:reply, %{status: :ok}, socket} @@ -183,7 +183,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), + with :ok <- validate_required_fields(event_data, expected_type), :ok <- validate_event_type(event_data, expected_type) do :ok end @@ -272,6 +272,9 @@ defmodule AshUI.LiveView.EventHandler do } 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 @@ -286,7 +289,7 @@ defmodule AshUI.LiveView.EventHandler do end end - defp execute_action(binding, event_data, socket, context) do + defp execute_action(binding, event_data, _socket, context) do case ActionBinding.execute_action(binding, event_data, context) do {:ok, result} -> {:ok, result} {:error, reason} -> {:error, reason} @@ -311,8 +314,13 @@ defmodule AshUI.LiveView.EventHandler do %{socket | assigns: Map.put(socket.assigns, :flash, updated_flash)} end - defp validate_required_fields(event_data) do - required = ["target"] + defp validate_required_fields(event_data, expected_type) do + required = + case expected_type do + "change" -> ["target", "data"] + _ -> ["target"] + end + missing = Enum.reject(required, &Map.has_key?(event_data, &1)) if missing == [] do @@ -355,7 +363,7 @@ defmodule AshUI.LiveView.EventHandler do bindings = socket.assigns[:ash_ui_bindings] || %{} # Create handler map for all bindings - handlers = ActionBinding.wire_handlers(Map.to_list(bindings), socket) + handlers = ActionBinding.wire_handlers(Map.values(bindings), socket) socket = Phoenix.Component.assign(socket, :ash_ui_handlers, handlers) {:ok, socket} diff --git a/lib/ash_ui/liveview/hooks.ex b/lib/ash_ui/liveview/hooks.ex index 3826ddf4..a0a2dc0e 100644 --- a/lib/ash_ui/liveview/hooks.ex +++ b/lib/ash_ui/liveview/hooks.ex @@ -20,7 +20,7 @@ defmodule AshUI.LiveView.Hooks do ## Assigns * `:ash_ui_loaded` - Set to true when screen is loaded """ - def on_mount_ash_ui(_params, session, socket) do + def on_mount_ash_ui(_params, _session, socket) do socket = socket |> Phoenix.Component.assign(:ash_ui_loaded, false) @@ -140,7 +140,7 @@ defmodule AshUI.LiveView.Hooks do """ def register_callback(socket, callback_type, callback_fn) when is_function(callback_fn, 1) do callbacks = Map.get(socket.assigns, :ash_ui_callbacks, %{}) - updated_callbacks = Map.update(callbacks, callback_type, [callback_fn], &[callback_fn | &1]) + updated_callbacks = Map.update(callbacks, callback_type, [callback_fn], &(&1 ++ [callback_fn])) Phoenix.Component.assign(socket, :ash_ui_callbacks, updated_callbacks) end @@ -228,7 +228,7 @@ defmodule AshUI.LiveView.Hooks do end) end - defp unsubscribe_from_resource(subscription) do + defp unsubscribe_from_resource(_subscription) do # Unsubscribe from Ash.Notifier # In production, would call Ash.Notifier.unsubscribe/1 :ok diff --git a/lib/ash_ui/liveview/lifecycle.ex b/lib/ash_ui/liveview/lifecycle.ex index f679e909..3712e646 100644 --- a/lib/ash_ui/liveview/lifecycle.ex +++ b/lib/ash_ui/liveview/lifecycle.ex @@ -10,7 +10,6 @@ defmodule AshUI.LiveView.Lifecycle do alias AshUI.Telemetry - alias AshUI.LiveView.Integration alias AshUI.LiveView.UpdateIntegration @type session_state :: %{ diff --git a/lib/ash_ui/liveview/liveview_integration.ex b/lib/ash_ui/liveview/liveview_integration.ex index e6e1fee4..6c147ecd 100644 --- a/lib/ash_ui/liveview/liveview_integration.ex +++ b/lib/ash_ui/liveview/liveview_integration.ex @@ -9,7 +9,6 @@ defmodule AshUI.LiveView.Integration do require Logger alias AshUI.Compiler - alias AshUI.Domain alias AshUI.Authorization.ScreenPolicy alias AshUI.Resources.Screen alias AshUI.Resources.Binding @@ -123,7 +122,7 @@ defmodule AshUI.LiveView.Integration do end end - defp load_screen(screen_id, user, params) do + defp load_screen(screen_id, user, _params) do case load_screen_by_identifier(screen_id, user) do {:ok, screen} -> {:ok, screen} {:error, :invalid_primary_key} -> {:error, :not_found} @@ -131,13 +130,13 @@ defmodule AshUI.LiveView.Integration do end end - defp load_screen_by_identifier(screen_id, user) when is_atom(screen_id) do + defp load_screen_by_identifier(screen_id, _user) when is_atom(screen_id) do load_screen_by_name(Atom.to_string(screen_id)) end defp load_screen_by_identifier(screen_id, user) do case load_screen_by_primary_key(screen_id, user) do - {:ok, screen} = result -> + {:ok, _screen} = result -> result {:error, _reason} when is_binary(screen_id) -> @@ -159,7 +158,7 @@ defmodule AshUI.LiveView.Integration do end defp load_screen_by_name(name) do - case Domain.read_one(Screen, filter: [name: name]) do + case AshUI.Data.read_one(Screen, filter: [name: name], authorize?: false) do {:ok, %Screen{} = screen} -> {:ok, screen} {:ok, nil} -> {:error, :not_found} {:error, reason} -> {:error, reason} @@ -234,7 +233,7 @@ defmodule AshUI.LiveView.Integration do end """ @spec redirect_to_login(Phoenix.LiveView.Socket.t(), term()) :: {:error, term()} - def redirect_to_login(socket, _error) do + def redirect_to_login(_socket, _error) do # In production, would use Phoenix.LiveView.redirect/3 # This is a placeholder for the redirect logic {:error, :unauthorized} diff --git a/lib/ash_ui/liveview/update_integration.ex b/lib/ash_ui/liveview/update_integration.ex index f56d02a4..807db5ac 100644 --- a/lib/ash_ui/liveview/update_integration.ex +++ b/lib/ash_ui/liveview/update_integration.ex @@ -8,9 +8,9 @@ defmodule AshUI.LiveView.UpdateIntegration do require Logger - alias AshUI.Resources.Binding alias AshUI.Runtime.BindingEvaluator alias AshUI.LiveView.Integration + alias AshUI.Resources.Screen @type subscription :: %{ id: String.t(), @@ -51,7 +51,7 @@ defmodule AshUI.LiveView.UpdateIntegration do case subscribe_to_resource(resource, subscription) do :ok -> - socket = track_subscription(socket, subscription) + track_subscription(socket, subscription) {:ok, subscription} {:error, reason} -> @@ -70,7 +70,7 @@ defmodule AshUI.LiveView.UpdateIntegration do def unsubscribe(socket, subscription) do case unsubscribe_from_resource(subscription) do :ok -> - socket = remove_subscription(socket, subscription) + remove_subscription(socket, subscription) :ok {:error, reason} -> @@ -91,7 +91,6 @@ defmodule AshUI.LiveView.UpdateIntegration do """ @spec handle_resource_change(map(), Phoenix.LiveView.Socket.t()) :: update_result() def handle_resource_change(notification, socket) do - screen = socket.assigns[:ash_ui_screen] bindings = socket.assigns[:ash_ui_bindings] || %{} with {:ok, affected_bindings} <- find_affected_bindings(notification, bindings), @@ -192,13 +191,19 @@ defmodule AshUI.LiveView.UpdateIntegration do user = socket.assigns[:ash_ui_user] params = socket.assigns[:ash_ui_params] || %{} - case Integration.evaluate_bindings(screen, socket, user, params) do - {:ok, bindings} -> - socket = Phoenix.Component.assign(socket, :ash_ui_bindings, bindings) - {:noreply, socket} + 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} - {:error, reason} -> - Logger.error("Failed to refresh bindings: #{inspect(reason)}") + {:error, reason} -> + Logger.error("Failed to refresh bindings: #{inspect(reason)}") + {:noreply, socket} + end + + _ -> {:noreply, socket} end end @@ -231,7 +236,7 @@ defmodule AshUI.LiveView.UpdateIntegration do "#{inspect(resource)}_#{:erlang.phash2(filter)}" end - defp subscribe_to_resource(resource, subscription) do + defp subscribe_to_resource(_resource, _subscription) do # Subscribe to Ash.Notifier # In production, would call Ash.Notifier.subscribe/2 try do @@ -242,7 +247,7 @@ defmodule AshUI.LiveView.UpdateIntegration do end end - defp unsubscribe_from_resource(subscription) do + defp unsubscribe_from_resource(_subscription) do # Unsubscribe from Ash.Notifier # In production, would call Ash.Notifier.unsubscribe/1 try do @@ -272,7 +277,7 @@ defmodule AshUI.LiveView.UpdateIntegration do defp get_notification_resource(%{resource: resource}), do: resource defp get_notification_resource(_), do: nil - defp find_affected_bindings(notification, bindings) do + defp find_affected_bindings(_notification, bindings) do # Find bindings that reference the changed resource affected = Enum.filter(bindings, fn {_id, _value} -> @@ -325,8 +330,18 @@ defmodule AshUI.LiveView.UpdateIntegration do end defp get_binding_by_id(binding_id, socket) do - # In production, would load binding from Ash - {:ok, %{id: binding_id}} + 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 end defp update_socket_assigns(socket, updated_values) do @@ -367,4 +382,12 @@ defmodule AshUI.LiveView.UpdateIntegration do :ok end + + defp refresh_screen_bindings(screen, socket, user, params) do + try do + Integration.evaluate_bindings(screen, socket, user, params) + rescue + error -> {:error, error} + end + end end diff --git a/lib/ash_ui/rendering/conversion_error.ex b/lib/ash_ui/rendering/conversion_error.ex index 7dbdf79d..00a74f15 100644 --- a/lib/ash_ui/rendering/conversion_error.ex +++ b/lib/ash_ui/rendering/conversion_error.ex @@ -48,6 +48,12 @@ defmodule AshUI.Rendering.ConversionError do base <> ": #{format_reason(error.reason)}" end + @impl true + @doc """ + Returns the exception message for logging and user-facing surfaces. + """ + def message(%__MODULE__{} = error), do: format_message(error) + defp format_reason(reason) when is_binary(reason), do: reason defp format_reason(reason) when is_atom(reason), do: Atom.to_string(reason) defp format_reason(reason), do: inspect(reason) diff --git a/lib/ash_ui/rendering/desktop_ui_adapter.ex b/lib/ash_ui/rendering/desktop_ui_adapter.ex index ae8a8c8d..6a252c13 100644 --- a/lib/ash_ui/rendering/desktop_ui_adapter.ex +++ b/lib/ash_ui/rendering/desktop_ui_adapter.ex @@ -66,7 +66,7 @@ defmodule AshUI.Rendering.DesktopUIAdapter do """ @spec available?() :: boolean() def available? do - Code.ensure_loaded?(DesktopUI.Renderer) + Code.ensure_loaded?(desktop_ui_renderer_module()) end @doc """ @@ -133,7 +133,7 @@ defmodule AshUI.Rendering.DesktopUIAdapter do * Menu bar configuration """ @spec configure_menu_bar(map(), keyword()) :: map() - def configure_menu_bar(%{"type" => "screen"} = iur, opts \\ []) do + def configure_menu_bar(%{"type" => "screen"} = _iur, opts \\ []) do enabled = Keyword.get(opts, :native_menu_bar, true) custom_items = Keyword.get(opts, :menu_items, []) @@ -240,10 +240,16 @@ defmodule AshUI.Rendering.DesktopUIAdapter do # Private Functions + defp desktop_ui_renderer_module do + Module.concat(DesktopUI, Renderer) + end + # Call actual DesktopUI.Renderer if available defp call_desktop_ui_renderer(canonical_iur, opts) do + renderer_module = desktop_ui_renderer_module() + try do - case DesktopUI.Renderer.render(canonical_iur, opts) do + case apply(renderer_module, :render, [canonical_iur, opts]) do {:ok, instructions} -> {:ok, instructions} {:error, reason} -> {:error, {:desktop_ui_error, reason}} other -> {:error, {:unexpected_response, other}} @@ -283,24 +289,6 @@ defmodule AshUI.Rendering.DesktopUIAdapter do generate_widget(widget) end - defp generate_menu_items(_iur) do - [ - %{ - "label" => "File", - "items" => [ - %{"label" => "Quit", "action" => "quit"} - ] - }, - %{ - "label" => "Edit", - "items" => [ - %{"label" => "Undo", "action" => "undo"}, - %{"label" => "Redo", "action" => "redo"} - ] - } - ] - end - defp generate_content(nil), do: [] defp generate_content([]), do: [] diff --git a/lib/ash_ui/rendering/live_ui_adapter.ex b/lib/ash_ui/rendering/live_ui_adapter.ex index fe6680ab..a8a932e5 100644 --- a/lib/ash_ui/rendering/live_ui_adapter.ex +++ b/lib/ash_ui/rendering/live_ui_adapter.ex @@ -65,7 +65,7 @@ defmodule AshUI.Rendering.LiveUIAdapter do """ @spec available?() :: boolean() def available? do - Code.ensure_loaded?(LiveUI.Renderer) + Code.ensure_loaded?(live_ui_renderer_module()) end @doc """ @@ -123,7 +123,7 @@ defmodule AshUI.Rendering.LiveUIAdapter do * Hook configuration list """ @spec configure_hooks(map(), keyword()) :: [map()] - def configure_hooks(%{"type" => "screen"} = iur, opts \\ []) do + def configure_hooks(%{"type" => "screen"} = _iur, opts \\ []) do custom_hooks = Keyword.get(opts, :hooks, []) optimize_patches = Keyword.get(opts, :optimize_patches, true) @@ -204,10 +204,16 @@ defmodule AshUI.Rendering.LiveUIAdapter do # Private Functions + defp live_ui_renderer_module do + Module.concat(LiveUI, Renderer) + end + # Call actual LiveUI.Renderer if available defp call_live_ui_renderer(canonical_iur, opts) do + renderer_module = live_ui_renderer_module() + try do - case LiveUI.Renderer.render(canonical_iur, opts) do + case apply(renderer_module, :render, [canonical_iur, opts]) do {:ok, heex} -> {:ok, heex} {:error, reason} -> {:error, {:live_ui_error, reason}} other -> {:error, {:unexpected_response, other}} diff --git a/lib/ash_ui/rendering/selector.ex b/lib/ash_ui/rendering/selector.ex index 9e74a6ea..bd18b352 100644 --- a/lib/ash_ui/rendering/selector.ex +++ b/lib/ash_ui/rendering/selector.ex @@ -50,12 +50,12 @@ defmodule AshUI.Rendering.Selector do 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, opts) + _error -> select_from_context(conn_or_map) end # Auto-detect from context true -> - select_from_context(conn_or_map, opts) + select_from_context(conn_or_map) end end @@ -160,7 +160,7 @@ defmodule AshUI.Rendering.Selector do # Private Functions - defp select_from_context(conn_or_map, opts) do + defp select_from_context(conn_or_map) do cond do liveview_request?(conn_or_map) -> get_renderer_with_validation(:liveview) @@ -202,7 +202,7 @@ defmodule AshUI.Rendering.Selector do end end - defp has_header?(conn_or_map, header_name, values \\ []) do + defp has_header?(conn_or_map, header_name, values) do header_value = get_request_header(conn_or_map, header_name) if is_binary(header_value) do @@ -218,7 +218,7 @@ defmodule AshUI.Rendering.Selector do end end - defp has_param?(conn_or_map, param_name, values \\ []) do + defp has_param?(conn_or_map, param_name, values) do param_value = get_request_param(conn_or_map, param_name) if param_value do @@ -243,8 +243,8 @@ defmodule AshUI.Rendering.Selector do conn |> Plug.Conn.get_req_header(header_name) |> case do - "" -> nil - val -> val + [value | _] -> value + [] -> nil end end diff --git a/lib/ash_ui/rendering/validation.ex b/lib/ash_ui/rendering/validation.ex index 6ee1fc85..2a08e39c 100644 --- a/lib/ash_ui/rendering/validation.ex +++ b/lib/ash_ui/rendering/validation.ex @@ -153,7 +153,7 @@ defmodule AshUI.Rendering.Validation do end end - defp collect_child_errors(errors, children, parent_iur) do + defp collect_child_errors(errors, children, _parent_iur) do Enum.reduce(children, errors, fn child, acc -> case validate_child(child) do :ok -> acc diff --git a/lib/ash_ui/rendering/web_ui_adapter.ex b/lib/ash_ui/rendering/web_ui_adapter.ex index 5c2bef7f..5bf25cc1 100644 --- a/lib/ash_ui/rendering/web_ui_adapter.ex +++ b/lib/ash_ui/rendering/web_ui_adapter.ex @@ -66,7 +66,7 @@ defmodule AshUI.Rendering.WebUIAdapter do """ @spec available?() :: boolean() def available? do - Code.ensure_loaded?(WebUI.Renderer) + Code.ensure_loaded?(web_ui_renderer_module()) end @doc """ @@ -181,7 +181,7 @@ defmodule AshUI.Rendering.WebUIAdapter do * SSG configuration """ @spec configure_ssg(map(), keyword()) :: map() - def configure_ssg(%{"type" => "screen"} = iur, opts \\ []) do + def configure_ssg(%{"type" => "screen"} = _iur, opts \\ []) do %{ output_path: Keyword.get(opts, :output_path, "output"), generate_index: Keyword.get(opts, :generate_index, true), @@ -193,10 +193,16 @@ defmodule AshUI.Rendering.WebUIAdapter do # Private Functions + defp web_ui_renderer_module do + Module.concat(WebUI, Renderer) + end + # Call actual WebUI.Renderer if available defp call_web_ui_renderer(canonical_iur, opts) do + renderer_module = web_ui_renderer_module() + try do - case WebUI.Renderer.render(canonical_iur, opts) do + case apply(renderer_module, :render, [canonical_iur, opts]) do {:ok, html} -> {:ok, html} {:error, reason} -> {:error, {:web_ui_error, reason}} other -> {:error, {:unexpected_response, other}} diff --git a/lib/ash_ui/runtime/action_binding.ex b/lib/ash_ui/runtime/action_binding.ex index d4bd4b80..7a0b7aae 100644 --- a/lib/ash_ui/runtime/action_binding.ex +++ b/lib/ash_ui/runtime/action_binding.ex @@ -60,6 +60,9 @@ defmodule AshUI.Runtime.ActionBinding do errors: nil }} else + {:error, :unauthorized} -> + {:error, :unauthorized} + {:error, reason} -> {:error, %{ @@ -88,7 +91,7 @@ defmodule AshUI.Runtime.ActionBinding do {:noreply, updated_socket} """ @spec event_handler(Binding.t() | map(), String.t()) :: function() - def event_handler(binding, element_id) do + def event_handler(binding, _element_id) do fn socket, event_data, _event_opts -> context = build_context(socket) @@ -113,15 +116,22 @@ defmodule AshUI.Runtime.ActionBinding do * Map of event_name to handler function """ @spec wire_handlers([Binding.t() | map()], map()) :: %{String.t() => function()} - def wire_handlers(bindings, socket) do + def wire_handlers(bindings, _socket) do action_bindings = - Enum.filter(bindings, fn b -> + 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") type in [:action, "action"] end) Enum.reduce(action_bindings, %{}, fn binding, acc -> - target = binding.target || Map.get(binding, "target") + target = Map.get(binding, :target) || Map.get(binding, "target") element_id = get_binding_element_id(binding) handler_name = "ash_ui_action_#{target || element_id}" @@ -202,7 +212,7 @@ defmodule AshUI.Runtime.ActionBinding do # Handle successful action defp handle_action_success(socket, binding, result) do - target = binding.target || Map.get(binding, "target") + target = Map.get(binding, :target) || Map.get(binding, "target") ash_ui = Map.get(socket.assigns, :ash_ui, %{}) actions = Map.get(ash_ui, :actions, %{}) action_state = Map.get(actions, target, %{}) @@ -229,7 +239,7 @@ defmodule AshUI.Runtime.ActionBinding do # Handle action error defp handle_action_error(socket, binding) do - target = binding.target || Map.get(binding, "target") + target = Map.get(binding, :target) || Map.get(binding, "target") ash_ui = Map.get(socket.assigns, :ash_ui, %{}) actions = Map.get(ash_ui, :actions, %{}) action_state = Map.get(actions, target, %{}) @@ -263,9 +273,6 @@ defmodule AshUI.Runtime.ActionBinding do defp format_action_error(:unauthorized), do: [%{"message" => "Unauthorized"}] defp format_action_error(reason), do: [%{"message" => inspect(reason)}] - defp get_binding_id(%Binding{id: id}), do: id - defp get_binding_id(binding), do: Map.get(binding, :id) || Map.get(binding, "id") - defp get_binding_element_id(binding) do Map.get(binding, :element_id) || Map.get(binding, "element_id") end diff --git a/lib/ash_ui/runtime/bidirectional_binding.ex b/lib/ash_ui/runtime/bidirectional_binding.ex index aa82767e..3420954c 100644 --- a/lib/ash_ui/runtime/bidirectional_binding.ex +++ b/lib/ash_ui/runtime/bidirectional_binding.ex @@ -87,9 +87,11 @@ defmodule AshUI.Runtime.BidirectionalBinding do * `{:ok, socket}` - Subscribed, socket with tracking info """ @spec subscribe_binding(Binding.t() | map(), socket(), context()) :: {:ok, socket()} - def subscribe_binding(binding, socket, context) 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 @@ -98,13 +100,18 @@ defmodule AshUI.Runtime.BidirectionalBinding do updated_subscriptions = Map.put(subscriptions, subscription_id, %{ binding_id: get_binding_id(binding), - source: binding.source, - target: binding.target, + source: source, + target: target, subscribed_at: System.system_time(:millisecond) }) - updated_socket = - put_in(socket.assigns, [:ash_ui, :subscriptions], updated_subscriptions) + updated_assigns = + put_in(socket.assigns, [ + Access.key(:ash_ui, %{}), + Access.key(:subscriptions, %{}) + ], updated_subscriptions) + + updated_socket = %{socket | assigns: updated_assigns} {:ok, updated_socket} end @@ -152,7 +159,7 @@ defmodule AshUI.Runtime.BidirectionalBinding do defp apply_validation(_binding, _value, nil), do: :ok - defp apply_validation(binding, value, validation_rules) when is_list(validation_rules) do + defp apply_validation(_binding, value, validation_rules) when is_list(validation_rules) do Enum.reduce_while(validation_rules, :ok, fn rule, _acc -> case validate_with_rule(rule, value) do :ok -> {:cont, :ok} @@ -202,12 +209,13 @@ defmodule AshUI.Runtime.BidirectionalBinding do end defp apply_sanitization(value, rules) do - Enum.reduce_while(rules, {:ok, value}, fn rule, {:ok, acc} -> - case sanitize_with_rule(rule, acc) do - {:ok, sanitized} -> {:cont, {:ok, sanitized}} - {:error, _} = error -> {:halt, error} - end - end) + sanitized = + Enum.reduce(rules, value, fn rule, acc -> + {:ok, next_value} = sanitize_with_rule(rule, acc) + next_value + end) + + {:ok, sanitized} end defp sanitize_with_rule(%{"type" => "trim"}, value) when is_binary(value) do @@ -238,7 +246,7 @@ defmodule AshUI.Runtime.BidirectionalBinding do end defp mock_update_result(_resource, _id, _field, value) do - {:ok, %{"status" => "updated", "value" => value}} + {:ok, %{status: :ok, value: value}} end # Helper functions for socket management @@ -303,7 +311,7 @@ defmodule AshUI.Runtime.BidirectionalBinding do } case result do - {:ok, updated_socket, update_result} = success -> + {:ok, updated_socket, update_result} -> Telemetry.emit( :binding, :update, @@ -313,7 +321,7 @@ defmodule AshUI.Runtime.BidirectionalBinding do {:ok, updated_socket, update_result} - {:error, reason, error_socket} = error -> + {:error, reason, error_socket} -> error_metadata = Map.merge(metadata, %{status: :error, error: inspect(reason)}) Telemetry.emit(:binding, :update, %{count: 1, duration: duration}, error_metadata) diff --git a/lib/ash_ui/runtime/binding_evaluator.ex b/lib/ash_ui/runtime/binding_evaluator.ex index c710a6e8..037fe95f 100644 --- a/lib/ash_ui/runtime/binding_evaluator.ex +++ b/lib/ash_ui/runtime/binding_evaluator.ex @@ -70,7 +70,7 @@ defmodule AshUI.Runtime.BindingEvaluator do end # Resolve source path to actual value - defp resolve_source(%{"resource" => resource} = source, context, opts) do + defp resolve_source(%{"resource" => _resource} = source, context, opts) do case Map.get(source, "action") do nil -> resolve_field_or_relationship(source, context, opts) action -> resolve_action(source, action, context, opts) @@ -104,27 +104,17 @@ defmodule AshUI.Runtime.BindingEvaluator do # Build Ash query to read the resource # In production, this would use the actual Ash domain and resources # For now, return a placeholder - case load_resource(resource_name, id, context) do - {:ok, resource} -> - value = get_field(resource, field) - {:ok, value} - - {:error, reason} -> - {:error, reason} - end + {: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 + defp resolve_relationship(resource_name, relationship, context, _opts) do parts = String.split(relationship, ".") - case load_resource(resource_name, nil, context) do - {:ok, resource} -> - navigate_relationship(resource, parts, context) - - {:error, reason} -> - {:error, reason} - end + {:ok, resource} = load_resource(resource_name, nil, context) + navigate_relationship(resource, parts, context) end # Navigate through nested relationships @@ -141,7 +131,7 @@ defmodule AshUI.Runtime.BindingEvaluator do end # Resolve an action source - defp resolve_action(source, action_name, context, _opts) do + defp resolve_action(source, action_name, _context, _opts) do resource = Map.get(source, "resource") # Actions don't have values to read @@ -183,14 +173,22 @@ defmodule AshUI.Runtime.BindingEvaluator do # Apply transformations to the resolved value defp apply_transformations(value, transform, _context) do - transforms = List.wrap(transform) - - Enum.reduce_while(transforms, {:ok, value}, fn transform, {:ok, acc} -> - case apply_single_transform(acc, transform) do - {:ok, new_value} -> {:cont, {:ok, new_value}} - {:error, _} = error -> {:halt, error} + transforms = + case transform do + nil -> [] + %{} = map when map_size(map) == 0 -> [] + %{} = map -> [map] + list when is_list(list) -> list + _ -> [] end - end) + + transformed = + Enum.reduce(transforms, value, fn transform, acc -> + {:ok, new_value} = apply_single_transform(acc, transform) + new_value + end) + + {:ok, transformed} end # Apply a single transformation @@ -232,9 +230,9 @@ defmodule AshUI.Runtime.BindingEvaluator do {:ok, value} end - defp apply_single_transform(_value, transform) do + defp apply_single_transform(value, _transform) do # Unknown transformation - pass through - {:ok, nil} + {:ok, value} end # Format a value (placeholder implementation) diff --git a/lib/ash_ui/runtime/list_binding.ex b/lib/ash_ui/runtime/list_binding.ex index 5b13087e..25897ed1 100644 --- a/lib/ash_ui/runtime/list_binding.ex +++ b/lib/ash_ui/runtime/list_binding.ex @@ -6,8 +6,6 @@ defmodule AshUI.Runtime.ListBinding do of Ash resources to UI elements like lists and tables. """ - alias AshUI.Runtime.BindingEvaluator - @type context :: %{ user_id: String.t() | nil, params: map(), @@ -46,7 +44,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 || %{} + source = binding_source(binding) resource = Map.get(source, "resource") relationship = Map.get(source, "relationship") @@ -84,16 +82,17 @@ defmodule AshUI.Runtime.ListBinding do * `{:ok, socket}` - Subscribed successfully """ @spec subscribe_collection(map(), map(), context()) :: {:ok, map()} - def subscribe_collection(binding, socket, context) do + def subscribe_collection(binding, socket, _context) do subscription_id = collection_subscription_id(binding) + source = binding_source(binding) # Track collection subscription subscriptions = get_in(socket.assigns, [:ash_ui, :list_subscriptions]) || %{} subscription = %{ binding_id: get_binding_id(binding), - resource: Map.get(binding.source, "resource"), - relationship: Map.get(binding.source, "relationship"), + resource: Map.get(source, "resource"), + relationship: Map.get(source, "relationship"), subscribed_at: System.system_time(:millisecond) } @@ -144,7 +143,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 = binding.transform || %{} + transform = Map.get(binding, :transform) || Map.get(binding, "transform") || %{} items = list_result.items formatted = @@ -164,21 +163,30 @@ defmodule AshUI.Runtime.ListBinding do 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 = - Enum.map(1..page_size, fn i -> - %{ - "id" => "#{resource}-#{relationship}-#{(page - 1) * page_size + i}", - "type" => relationship, - "index" => (page - 1) * page_size + i - } - end) + 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" => 100, # Mock total - "page" => page + "items" => items, + "total" => total, + "page" => page }} end @@ -190,25 +198,44 @@ defmodule AshUI.Runtime.ListBinding do Map.get(collection, "items", []) end - defp handle_insert(binding, change_data, socket, context) do + defp handle_insert(binding, change_data, socket, _context) do # For insert, we may want to prepend to the list or refresh - target = binding.target || Map.get(binding, "target") + target = Map.get(binding, :target) || Map.get(binding, "target") # Store change for UI update - changes = get_in(socket.assigns, [:ash_ui, :list_changes, target]) || [] + changes = + get_in(socket.assigns, [ + Access.key(:ash_ui, %{}), + Access.key(:list_changes, %{}), + Access.key(target, []) + ]) + updated_changes = [{:insert, change_data} | changes] - updated_socket = put_in(socket.assigns, [:ash_ui, :list_changes, target], updated_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} {:ok, updated_socket, true} end defp handle_update(binding, change_data, socket, _context) do # For update, find the item and update it - target = binding.target || Map.get(binding, "target") + target = Map.get(binding, :target) || Map.get(binding, "target") item_id = Map.get(change_data, "id") # Update the item in the cached list - items = get_in(socket.assigns, [:ash_ui, :lists, target, "items"]) || [] + items = + get_in(socket.assigns, [ + Access.key(:ash_ui, %{}), + Access.key(:lists, %{}), + Access.key(target, %{}), + Access.key("items", []) + ]) updated_items = Enum.map(items, fn item -> @@ -219,26 +246,59 @@ defmodule AshUI.Runtime.ListBinding do end end) - updated_socket = put_in(socket.assigns, [:ash_ui, :lists, target, "items"], updated_items) + 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} {:ok, updated_socket, true} end defp handle_delete(binding, change_data, socket, _context) do # For delete, remove the item from the list - target = binding.target || Map.get(binding, "target") + target = Map.get(binding, :target) || Map.get(binding, "target") item_id = Map.get(change_data, "id") - items = get_in(socket.assigns, [:ash_ui, :lists, target, "items"]) || [] + 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) - - updated_socket = - put_in(socket.assigns, [: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_in(socket.assigns, [:ash_ui, :lists, target, "total"], current_total - 1) + 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) + + 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} {:ok, updated_socket, true} end @@ -288,9 +348,14 @@ 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 - resource = get_in(binding, [:source, "resource"]) - relationship = get_in(binding, [:source, "relationship"]) + source = binding_source(binding) + resource = Map.get(source, "resource") + relationship = Map.get(source, "relationship") "list_#{resource}_#{relationship}" end end diff --git a/lib/ash_ui/signal/cloud_events.ex b/lib/ash_ui/signal/cloud_events.ex index 95d51d1c..8a0498e8 100644 --- a/lib/ash_ui/signal/cloud_events.ex +++ b/lib/ash_ui/signal/cloud_events.ex @@ -9,12 +9,7 @@ defmodule AshUI.Signal.CloudEvents do alias AshUI.Signal.Struct @type cloud_event :: %{ - required: [String.t()], - "id": String.t(), - "source": String.t(), - "type": String.t(), - "datacontenttype": String.t(), - "data": map() + required(String.t()) => term() } @doc """ @@ -117,7 +112,8 @@ defmodule AshUI.Signal.CloudEvents do "{\\"id\\": \\"signal-123\\", ...}" """ @spec serialize(cloud_event() | Struct.t(), keyword()) :: String.t() | binary() - def serialize(%Struct{} = signal, opts \\ []) do + def serialize(cloud_event_or_signal, opts \\ []) + def serialize(%Struct{} = signal, opts) do event = to_cloud_event(signal) serialize(event, opts) end @@ -228,11 +224,6 @@ defmodule AshUI.Signal.CloudEvents do end @type envelope :: %{ - "specversion": String.t(), - "id": String.t(), - "source": String.t(), - "type": String.t(), - "datacontenttype": String.t(), - "data": map() + required(String.t()) => term() } end diff --git a/lib/ash_ui/signal/struct.ex b/lib/ash_ui/signal/struct.ex index 0c90d459..c75fa24e 100644 --- a/lib/ash_ui/signal/struct.ex +++ b/lib/ash_ui/signal/struct.ex @@ -55,7 +55,7 @@ defmodule AshUI.Signal.Struct do """ @spec new(keyword()) :: t() def new(opts \\ []) do - id = Keyword.get(opts, :id, generate_id()) + id = Keyword.get(opts, :id) || generate_id() source = Keyword.get(opts, :source, %{}) target = Keyword.get(opts, :target, "") type = Keyword.get(opts, :type, :bidirectional) @@ -102,7 +102,7 @@ defmodule AshUI.Signal.Struct do """ @spec collection(String.t(), String.t(), keyword()) :: t() def collection(source_path, target, opts \\ []) do - source = parse_source_path(source_path) + source = parse_collection_source_path(source_path) new( id: Keyword.get(opts, :id), @@ -195,15 +195,32 @@ defmodule AshUI.Signal.Struct do parts -> # Handle nested relationships - case parse_relationship_path(parts) do - {:ok, source} -> source - :error -> %{"type" => "path", "path" => path} - end + {:ok, source} = parse_relationship_path(parts) + source end end defp parse_source_path(source) when is_map(source), do: normalize_source(source) + defp parse_collection_source_path(path) when is_binary(path) do + case String.split(path, ".", trim: true) do + [resource] -> + %{"type" => "resource", "resource" => resource} + + [resource, relationship] -> + %{"type" => "relationship", "resource" => resource, "relationship" => relationship} + + [resource | relationship_parts] -> + %{ + "type" => "relationship", + "resource" => resource, + "relationship" => Enum.join(relationship_parts, ".") + } + end + end + + defp parse_collection_source_path(source) when is_map(source), do: normalize_source(source) + defp parse_relationship_path([resource | relationship_parts]) do { :ok, @@ -216,6 +233,8 @@ defmodule AshUI.Signal.Struct do end # Normalize source map to ensure required fields + defp normalize_source(source) when is_map(source) and map_size(source) == 0, do: %{} + defp normalize_source(source) when is_map(source) do Map.put_new(source, "type", "custom") end diff --git a/test/ash_ui/authorization/phase_5_integration_test.exs b/test/ash_ui/authorization/phase_5_integration_test.exs index 9b476827..dc816616 100644 --- a/test/ash_ui/authorization/phase_5_integration_test.exs +++ b/test/ash_ui/authorization/phase_5_integration_test.exs @@ -12,17 +12,15 @@ defmodule AshUI.Authorization.Phase5IntegrationTest do 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", @@ -110,7 +108,7 @@ defmodule AshUI.Authorization.Phase5IntegrationTest do assert {:forbidden, reason} = Runtime.check_action_authorization(user, :update, %{}) assert reason.reason == :inactive - assert reason.message != nil + assert is_binary(reason.message) end test "partial authorization allows some fields" do @@ -148,12 +146,10 @@ defmodule AshUI.Authorization.Phase5IntegrationTest do # Redacted value should be placeholder redacted = BindingPolicy.redacted_value(binding) - assert redacted == "[PROTECTED]" or redacted == [] + assert Enum.member?(["[PROTECTED]", []], 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 @@ -244,7 +240,7 @@ 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 {:forbidden, _reason} = Runtime.check_mount_authorization(nil, screen) assert AuthorizationError.requires_login?(AuthorizationError.unauthenticated(AshUI.Screen, :mount)) end @@ -298,7 +294,7 @@ defmodule AshUI.Authorization.Phase5IntegrationTest do error = AuthorizationError.forbidden(AshUI.Screen, :mount) page = AuthorizationError.custom_error_page(error, AshUI.Screen) - assert page.help_url != nil + assert is_binary(page.help_url) assert String.contains?(page.help_url, "/help/") end end diff --git a/test/ash_ui/authorization/policies_test.exs b/test/ash_ui/authorization/policies_test.exs index c0f348ee..f3591c18 100644 --- a/test/ash_ui/authorization/policies_test.exs +++ b/test/ash_ui/authorization/policies_test.exs @@ -7,8 +7,6 @@ defmodule AshUI.Authorization.PoliciesTest do defp build_admin(), do: %{id: "admin-1", role: :admin, active: true} defp build_user(), do: %{id: "user-1", 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 resources defp build_screen(opts \\ []) do Enum.into(opts, %{ diff --git a/test/ash_ui/authorization/resource_policies_test.exs b/test/ash_ui/authorization/resource_policies_test.exs index a48d79ef..9900b328 100644 --- a/test/ash_ui/authorization/resource_policies_test.exs +++ b/test/ash_ui/authorization/resource_policies_test.exs @@ -12,7 +12,7 @@ defmodule AshUI.Authorization.ResourcePoliciesTest do defp build_guest(), do: %{id: nil, role: :guest, active: true} # Mock resources - defp build_screen(opts \\ []) do + defp build_screen(opts) do Enum.into(opts, %{ id: "screen-1", name: "Test Screen", diff --git a/test/ash_ui/authorization/runtime_test.exs b/test/ash_ui/authorization/runtime_test.exs index 042d0553..bdae8461 100644 --- a/test/ash_ui/authorization/runtime_test.exs +++ b/test/ash_ui/authorization/runtime_test.exs @@ -9,7 +9,7 @@ defmodule AshUI.Authorization.RuntimeTest do defp build_inactive(), do: %{id: "user-2", role: :user, active: false} # Mock socket - defp build_socket(assigns \\ %{}) do + defp build_socket(assigns) do %Phoenix.LiveView.Socket{ assigns: Enum.into(assigns, %{__changed__: %{}}) } @@ -89,8 +89,10 @@ defmodule AshUI.Authorization.RuntimeTest do end test "includes error message in forbidden response" do - assert {:forbidden, reason} = Runtime.check_action_authorization(build_inactive(), :delete, %{}) - assert reason.message != nil + assert {:forbidden, reason} = + Runtime.check_action_authorization(build_inactive(), :delete, %{}) + + assert is_binary(reason.message) end end diff --git a/test/ash_ui/compiler/extensions_test.exs b/test/ash_ui/compiler/extensions_test.exs index f327a276..456bcd0e 100644 --- a/test/ash_ui/compiler/extensions_test.exs +++ b/test/ash_ui/compiler/extensions_test.exs @@ -52,7 +52,7 @@ defmodule AshUI.Compiler.ExtensionsTest do end test "returns empty list when no widgets registered" do - assert Extensions.registered_widgets() == [] + assert Enum.empty?(Extensions.registered_widgets()) end test "returns list of registered widgets" do @@ -77,7 +77,7 @@ defmodule AshUI.Compiler.ExtensionsTest do end test "returns empty list when no layouts registered" do - assert Extensions.registered_layouts() == [] + assert Enum.empty?(Extensions.registered_layouts()) end end @@ -373,8 +373,8 @@ defmodule AshUI.Compiler.ExtensionsTest do Extensions.init() # Tables should exist now - assert Extensions.available_widget_types() != [] - assert Extensions.available_layout_types() != [] + refute Enum.empty?(Extensions.available_widget_types()) + refute Enum.empty?(Extensions.available_layout_types()) end end end diff --git a/test/ash_ui/compiler/incremental_test.exs b/test/ash_ui/compiler/incremental_test.exs index ebe4ee36..6d0df2ae 100644 --- a/test/ash_ui/compiler/incremental_test.exs +++ b/test/ash_ui/compiler/incremental_test.exs @@ -1,5 +1,5 @@ defmodule AshUI.Compiler.IncrementalTest do - use ExUnit.Case, async: false + use AshUI.DataCase, async: false alias AshUI.Compiler.Incremental alias AshUI.Resources.Screen @@ -9,7 +9,7 @@ defmodule AshUI.Compiler.IncrementalTest do describe "build_dependencies/1" do setup do {:ok, screen} = - AshUI.Domain.create(Screen, + AshUI.Data.create(Screen, attrs: %{ name: "incremental_test_screen", unified_dsl: %{"type" => "screen"}, @@ -19,7 +19,7 @@ defmodule AshUI.Compiler.IncrementalTest do # Create elements {:ok, element1} = - AshUI.Domain.create(Element, + AshUI.Data.create(Element, attrs: %{ type: :text, props: %{"content" => "Text 1"}, @@ -29,7 +29,7 @@ defmodule AshUI.Compiler.IncrementalTest do ) {:ok, element2} = - AshUI.Domain.create(Element, + AshUI.Data.create(Element, attrs: %{ type: :button, props: %{"label" => "Button"}, @@ -40,7 +40,7 @@ defmodule AshUI.Compiler.IncrementalTest do # Create binding {:ok, _binding} = - AshUI.Domain.create(Binding, + AshUI.Data.create(Binding, attrs: %{ source: %{"resource" => "Test", "field" => "value"}, target: "test_target", @@ -87,7 +87,7 @@ defmodule AshUI.Compiler.IncrementalTest do describe "affects_screen?/4" do setup do {:ok, screen} = - AshUI.Domain.create(Screen, + AshUI.Data.create(Screen, attrs: %{ name: "affects_test_screen", unified_dsl: %{"type" => "screen"}, @@ -96,7 +96,7 @@ defmodule AshUI.Compiler.IncrementalTest do ) {:ok, element} = - AshUI.Domain.create(Element, + AshUI.Data.create(Element, attrs: %{ type: :text, props: %{"content" => "Test"}, @@ -122,7 +122,7 @@ defmodule AshUI.Compiler.IncrementalTest do describe "get_dependents/3" do setup do {:ok, screen} = - AshUI.Domain.create(Screen, + AshUI.Data.create(Screen, attrs: %{ name: "dependents_test_screen", unified_dsl: %{"type" => "screen"}, @@ -131,7 +131,7 @@ defmodule AshUI.Compiler.IncrementalTest do ) {:ok, element} = - AshUI.Domain.create(Element, + AshUI.Data.create(Element, attrs: %{ type: :text, props: %{"content" => "Test"}, @@ -141,7 +141,7 @@ defmodule AshUI.Compiler.IncrementalTest do ) {:ok, _binding} = - AshUI.Domain.create(Binding, + AshUI.Data.create(Binding, attrs: %{ source: %{"resource" => "Test", "field" => "value"}, target: "test_target", @@ -193,7 +193,7 @@ defmodule AshUI.Compiler.IncrementalTest do describe "recompile_on_change/4" do setup do {:ok, screen} = - AshUI.Domain.create(Screen, + AshUI.Data.create(Screen, attrs: %{ name: "recompile_test_screen", unified_dsl: %{"type" => "screen"}, diff --git a/test/ash_ui/compiler/phase_6_integration_test.exs b/test/ash_ui/compiler/phase_6_integration_test.exs index bf5a44eb..abcb424d 100644 --- a/test/ash_ui/compiler/phase_6_integration_test.exs +++ b/test/ash_ui/compiler/phase_6_integration_test.exs @@ -1,24 +1,10 @@ defmodule AshUI.Compiler.Phase6IntegrationTest do - use ExUnit.Case, async: false + use AshUI.DataCase, async: false alias AshUI.Compiler alias AshUI.Compiler.Incremental alias AshUI.Compiler.Extensions alias AshUI.DSL.Builder - alias AshUI.DSL.Storage - - # Mock resources - defp build_screen(opts \\ []) do - struct(AshUI.Resources.Screen, - id: Keyword.get(opts, :id, "screen-1"), - name: Keyword.get(opts, :name, "Test Screen"), - version: Keyword.get(opts, :version, 1), - layout: Keyword.get(opts, :layout, "vertical"), - route: Keyword.get(opts, :route, "/test"), - unified_dsl: Keyword.get(opts, :unified_dsl), - metadata: Keyword.get(opts, :metadata, %{}) - ) - end defp default_dsl do Builder.row(children: [ @@ -59,7 +45,7 @@ defmodule AshUI.Compiler.Phase6IntegrationTest do dsl = Builder.text("Simple Screen") {:ok, screen} = - AshUI.Domain.create(AshUI.Resources.Screen, + AshUI.Data.create(AshUI.Resources.Screen, attrs: %{ name: "simple_screen", unified_dsl: Builder.to_store(dsl), @@ -81,7 +67,7 @@ defmodule AshUI.Compiler.Phase6IntegrationTest do ]) {:ok, screen} = - AshUI.Domain.create(AshUI.Resources.Screen, + AshUI.Data.create(AshUI.Resources.Screen, attrs: %{ name: "complex_screen", unified_dsl: Builder.to_store(dsl), @@ -102,7 +88,7 @@ defmodule AshUI.Compiler.Phase6IntegrationTest do } {:ok, screen} = - AshUI.Domain.create(AshUI.Resources.Screen, + AshUI.Data.create(AshUI.Resources.Screen, attrs: %{ name: "invalid_screen", unified_dsl: invalid_dsl, @@ -117,7 +103,7 @@ defmodule AshUI.Compiler.Phase6IntegrationTest do dsl = Builder.text("Cached Screen") {:ok, screen} = - AshUI.Domain.create(AshUI.Resources.Screen, + AshUI.Data.create(AshUI.Resources.Screen, attrs: %{ name: "cached_screen", unified_dsl: Builder.to_store(dsl), @@ -126,10 +112,10 @@ defmodule AshUI.Compiler.Phase6IntegrationTest do ) # First compilation - assert {:ok, iur1} = Compiler.compile(screen, use_cache: true) + assert {:ok, _iur1} = Compiler.compile(screen, use_cache: true) # Second compilation should hit cache - assert {:ok, iur2} = Compiler.compile(screen, use_cache: true) + assert {:ok, _iur2} = Compiler.compile(screen, use_cache: true) stats = Compiler.cache_stats() assert stats.hits >= 1 @@ -139,7 +125,7 @@ defmodule AshUI.Compiler.Phase6IntegrationTest do describe "Section 6.5.3 - Incremental compilation scenarios" do test "element change triggers screen recompile" do {:ok, screen} = - AshUI.Domain.create(AshUI.Resources.Screen, + AshUI.Data.create(AshUI.Resources.Screen, attrs: %{ name: "incremental_screen", unified_dsl: Builder.to_store(default_dsl()), @@ -148,7 +134,7 @@ defmodule AshUI.Compiler.Phase6IntegrationTest do ) {:ok, element} = - AshUI.Domain.create(AshUI.Resources.Element, + AshUI.Data.create(AshUI.Resources.Element, attrs: %{ type: :text, props: %{"content" => "Initial"}, @@ -168,7 +154,7 @@ defmodule AshUI.Compiler.Phase6IntegrationTest do Compiler.clear_cache() {:ok, screen} = - AshUI.Domain.create(AshUI.Resources.Screen, + AshUI.Data.create(AshUI.Resources.Screen, attrs: %{ name: "cache_test_screen", unified_dsl: Builder.to_store(default_dsl()), @@ -188,7 +174,7 @@ defmodule AshUI.Compiler.Phase6IntegrationTest do test "dependency tracking works" do {:ok, screen} = - AshUI.Domain.create(AshUI.Resources.Screen, + AshUI.Data.create(AshUI.Resources.Screen, attrs: %{ name: "dependency_screen", unified_dsl: Builder.to_store(default_dsl()), @@ -197,7 +183,7 @@ defmodule AshUI.Compiler.Phase6IntegrationTest do ) {:ok, element} = - AshUI.Domain.create(AshUI.Resources.Element, + AshUI.Data.create(AshUI.Resources.Element, attrs: %{ type: :text, props: %{"content" => "Test"}, @@ -206,8 +192,8 @@ defmodule AshUI.Compiler.Phase6IntegrationTest do } ) - {:ok, _binding} = - AshUI.Domain.create(AshUI.Resources.Binding, + {:ok, binding} = + AshUI.Data.create(AshUI.Resources.Binding, attrs: %{ source: %{"resource" => "Test", "field" => "value"}, target: "test_target", @@ -223,7 +209,7 @@ defmodule AshUI.Compiler.Phase6IntegrationTest do assert graph.element_to_screen[element.id] == screen.id # Check binding to element dependency - assert graph.binding_to_element[Integer.to_string(element.id)] == element.id + assert graph.binding_to_element[binding.id] == element.id end test "circular dependencies are detected" do @@ -323,7 +309,7 @@ defmodule AshUI.Compiler.Phase6IntegrationTest do # Store in database {:ok, screen} = - AshUI.Domain.create(AshUI.Resources.Screen, + AshUI.Data.create(AshUI.Resources.Screen, attrs: %{ name: "full_pipeline_screen", unified_dsl: Builder.to_store(dsl), @@ -350,7 +336,7 @@ defmodule AshUI.Compiler.Phase6IntegrationTest do } {:ok, screen} = - AshUI.Domain.create(AshUI.Resources.Screen, + AshUI.Data.create(AshUI.Resources.Screen, attrs: %{ name: "error_screen", unified_dsl: invalid_dsl, @@ -369,7 +355,7 @@ defmodule AshUI.Compiler.Phase6IntegrationTest do dsl = Builder.text("Performance Test") {:ok, screen} = - AshUI.Domain.create(AshUI.Resources.Screen, + AshUI.Data.create(AshUI.Resources.Screen, attrs: %{ name: "perf_screen", unified_dsl: Builder.to_store(dsl), diff --git a/test/ash_ui/compiler_test.exs b/test/ash_ui/compiler_test.exs index 1c4f586e..d7aa7b24 100644 --- a/test/ash_ui/compiler_test.exs +++ b/test/ash_ui/compiler_test.exs @@ -10,7 +10,7 @@ defmodule AshUI.CompilerTest do describe "compile/2" do setup do {:ok, screen} = - AshUI.Domain.create(Screen, + AshUI.Data.create(Screen, attrs: %{ name: "compiler_test_screen", unified_dsl: %{"type" => "screen"}, @@ -21,7 +21,7 @@ defmodule AshUI.CompilerTest do # Create test elements {:ok, element1} = - AshUI.Domain.create(Element, + AshUI.Data.create(Element, attrs: %{ type: :text, props: %{"content" => "Hello"}, @@ -31,7 +31,7 @@ defmodule AshUI.CompilerTest do ) {:ok, element2} = - AshUI.Domain.create(Element, + AshUI.Data.create(Element, attrs: %{ type: :button, props: %{"label" => "Click me"}, @@ -42,7 +42,7 @@ defmodule AshUI.CompilerTest do # Create test binding {:ok, _binding} = - AshUI.Domain.create(Binding, + AshUI.Data.create(Binding, attrs: %{ source: %{"resource" => "Test", "field" => "value"}, target: "test_target", @@ -65,7 +65,7 @@ defmodule AshUI.CompilerTest do assert iur.attributes["route"] == "/compiler-test" end - test "compiles elements as IUR children", %{screen: screen, elements: elements} do + test "compiles elements as IUR children", %{screen: screen} do assert {:ok, %IUR{} = iur} = Compiler.compile(screen) assert length(iur.children) == 2 @@ -101,7 +101,7 @@ defmodule AshUI.CompilerTest do describe "compile/2 with options" do setup do {:ok, screen} = - AshUI.Domain.create(Screen, + AshUI.Data.create(Screen, attrs: %{ name: "options_test_screen", unified_dsl: %{"type" => "screen"}, @@ -150,7 +150,7 @@ defmodule AshUI.CompilerTest do } {:ok, screen} = - AshUI.Domain.create(Screen, + AshUI.Data.create(Screen, attrs: %{ name: "dsl_test_screen", unified_dsl: dsl, @@ -176,7 +176,7 @@ defmodule AshUI.CompilerTest do } {:ok, screen} = - AshUI.Domain.create(Screen, + AshUI.Data.create(Screen, attrs: %{ name: "invalid_dsl_screen", unified_dsl: invalid_dsl, @@ -204,7 +204,7 @@ defmodule AshUI.CompilerTest do } {:ok, screen} = - AshUI.Domain.create(Screen, + AshUI.Data.create(Screen, attrs: %{ name: "cache_test_screen", unified_dsl: dsl, @@ -214,6 +214,7 @@ defmodule AshUI.CompilerTest do assert {:ok, iur1} = Compiler.compile(screen, use_cache: true) assert {:ok, iur2} = Compiler.compile(screen, use_cache: true) + assert iur1 == iur2 # Should get same result stats = Compiler.cache_stats() @@ -230,7 +231,7 @@ defmodule AshUI.CompilerTest do } {:ok, screen} = - AshUI.Domain.create(Screen, + AshUI.Data.create(Screen, attrs: %{ name: "no_cache_screen", unified_dsl: dsl, @@ -255,7 +256,7 @@ defmodule AshUI.CompilerTest do } {:ok, screen} = - AshUI.Domain.create(Screen, + AshUI.Data.create(Screen, attrs: %{ name: "invalidate_screen", unified_dsl: dsl, @@ -298,12 +299,12 @@ defmodule AshUI.CompilerTest do dsl2 = %{type: "button", props: %{}, children: [], signals: [], metadata: %{}} {:ok, screen1} = - AshUI.Domain.create(Screen, + AshUI.Data.create(Screen, attrs: %{name: "batch_screen_1", unified_dsl: dsl1, layout: :row} ) {:ok, screen2} = - AshUI.Domain.create(Screen, + AshUI.Data.create(Screen, attrs: %{name: "batch_screen_2", unified_dsl: dsl2, layout: :row} ) diff --git a/test/ash_ui/dsl_integration_test.exs b/test/ash_ui/dsl_integration_test.exs index cf96d2cc..1c960a4f 100644 --- a/test/ash_ui/dsl_integration_test.exs +++ b/test/ash_ui/dsl_integration_test.exs @@ -15,7 +15,7 @@ defmodule AshUI.DSLIntegrationTest do route: "/dsl-test" } - assert {:ok, screen} = AshUI.Domain.create(Screen, attrs: attrs) + assert {:ok, screen} = AshUI.Data.create(Screen, attrs: attrs) assert screen.layout == :row assert screen.route == "/dsl-test" assert is_map(screen.unified_dsl) @@ -28,7 +28,7 @@ defmodule AshUI.DSLIntegrationTest do metadata: %{"custom" => "value", "priority" => 1} } - assert {:ok, screen} = AshUI.Domain.create(Screen, attrs: attrs) + assert {:ok, screen} = AshUI.Data.create(Screen, attrs: attrs) assert screen.metadata == %{"custom" => "value", "priority" => 1} end end @@ -36,7 +36,7 @@ defmodule AshUI.DSLIntegrationTest do describe "ui_element DSL extension" do setup do {:ok, screen} = - AshUI.Domain.create(Screen, + AshUI.Data.create(Screen, attrs: %{ name: "element_dsl_test", unified_dsl: %{"type" => "screen"}, @@ -76,7 +76,7 @@ defmodule AshUI.DSLIntegrationTest do position: 1 } - assert {:ok, _element} = AshUI.Domain.create(Element, attrs: attrs) + assert {:ok, _element} = AshUI.Data.create(Element, attrs: attrs) end) end @@ -89,7 +89,7 @@ defmodule AshUI.DSLIntegrationTest do position: 1 } - assert {:ok, element} = AshUI.Domain.create(Element, attrs: attrs) + assert {:ok, element} = AshUI.Data.create(Element, attrs: attrs) assert element.props == %{"label" => "Click me", "disabled" => false} assert element.variants == [:primary, :large] end @@ -98,7 +98,7 @@ defmodule AshUI.DSLIntegrationTest do describe "ui_binding DSL extension" do setup do {:ok, screen} = - AshUI.Domain.create(Screen, + AshUI.Data.create(Screen, attrs: %{ name: "binding_dsl_test", unified_dsl: %{"type" => "screen"}, @@ -107,7 +107,7 @@ defmodule AshUI.DSLIntegrationTest do ) {:ok, element} = - AshUI.Domain.create(Element, + AshUI.Data.create(Element, attrs: %{ type: :textinput, props: %{}, @@ -134,7 +134,7 @@ defmodule AshUI.DSLIntegrationTest do screen_id: screen.id } - assert {:ok, _binding} = AshUI.Domain.create(Binding, attrs: attrs) + assert {:ok, _binding} = AshUI.Data.create(Binding, attrs: attrs) end) end @@ -148,7 +148,7 @@ defmodule AshUI.DSLIntegrationTest do screen_id: screen.id } - assert {:ok, binding} = AshUI.Domain.create(Binding, attrs: attrs) + assert {:ok, binding} = AshUI.Data.create(Binding, attrs: attrs) assert binding.transform == %{"function" => "uppercase", "args" => []} end end @@ -167,7 +167,7 @@ defmodule AshUI.DSLIntegrationTest do } # Should fail due to nil foreign keys, not invalid type - assert {:error, _error} = AshUI.Domain.create(Binding, attrs: attrs) + assert {:error, _error} = AshUI.Data.create(Binding, attrs: attrs) end end end diff --git a/test/ash_ui/liveview/error_handler_test.exs b/test/ash_ui/liveview/error_handler_test.exs index eeeed98a..97aa6ded 100644 --- a/test/ash_ui/liveview/error_handler_test.exs +++ b/test/ash_ui/liveview/error_handler_test.exs @@ -62,7 +62,7 @@ defmodule AshUI.LiveView.ErrorHandlerTest do socket = build_socket(ash_ui_user: build_user()) binding = %{id: "binding-1"} - assert {:error, _reason} = + assert {:error, _reason, socket} = ErrorHandler.handle_binding_error(binding, :not_found, socket) # Error should be stored in binding errors @@ -73,7 +73,8 @@ defmodule AshUI.LiveView.ErrorHandlerTest do socket = build_socket() binding = %{id: "binding-1"} - assert {:error, :not_found} = ErrorHandler.handle_binding_error(binding, :not_found, socket) + assert {:error, :not_found, _socket} = + ErrorHandler.handle_binding_error(binding, :not_found, socket) end end @@ -288,7 +289,7 @@ defmodule AshUI.LiveView.ErrorHandlerTest do :atomics.put(attempts, 1, 0) operation = fn -> - count = :atomics.increment_get(attempts, 1) + count = :atomics.add_get(attempts, 1, 1) if count < 3, do: {:error, :retry}, else: {:ok, :success} end diff --git a/test/ash_ui/liveview/event_handler_test.exs b/test/ash_ui/liveview/event_handler_test.exs index eb01d4b5..f70b0fdc 100644 --- a/test/ash_ui/liveview/event_handler_test.exs +++ b/test/ash_ui/liveview/event_handler_test.exs @@ -53,21 +53,21 @@ defmodule AshUI.LiveView.EventHandlerTest do socket = build_socket(ash_ui_bindings: %{}) event = %{type: :change, target: "input-1", data: %{"value" => "test"}} - assert {:ok, socket} = EventHandler.route_event(event, socket) + assert {:ok, _updated_socket} = EventHandler.route_event(event, socket) end test "routes click events to action handler" do socket = build_socket(ash_ui_bindings: %{}) event = %{type: :click, target: "button-1", data: %{}} - assert {:ok, socket} = EventHandler.route_event(event, socket) + assert {:ok, _updated_socket} = EventHandler.route_event(event, socket) end test "routes submit events to action handler" do socket = build_socket(ash_ui_bindings: %{}) event = %{type: :submit, target: "form-1", data: %{}} - assert {:ok, socket} = EventHandler.route_event(event, socket) + assert {:ok, _updated_socket} = EventHandler.route_event(event, socket) end test "returns error for unknown event types" do @@ -88,7 +88,7 @@ defmodule AshUI.LiveView.EventHandlerTest do params = %{"target" => "input-1", "value" => "new value"} - assert {:noreply, socket} = EventHandler.handle_value_change(params, socket) + assert {:noreply, _updated_socket} = EventHandler.handle_value_change(params, socket) end test "assigns flash on error" do @@ -100,7 +100,7 @@ defmodule AshUI.LiveView.EventHandlerTest do params = %{"target" => "nonexistent", "value" => "test"} - assert {:noreply, socket} = EventHandler.handle_value_change(params, socket) + assert {:noreply, _updated_socket} = EventHandler.handle_value_change(params, socket) end end @@ -116,7 +116,7 @@ defmodule AshUI.LiveView.EventHandlerTest do params = %{"action_id" => "action1", "data" => %{"name" => "Test"}} - assert {:reply, reply, socket} = EventHandler.handle_action_event(params, socket) + assert {:reply, reply, _updated_socket} = EventHandler.handle_action_event(params, socket) assert reply[:status] in [:ok, :error] end @@ -129,7 +129,7 @@ defmodule AshUI.LiveView.EventHandlerTest do params = %{"action_id" => "restricted_action", "data" => %{}} - assert {:reply, reply, socket} = EventHandler.handle_action_event(params, socket) + assert {:reply, reply, _updated_socket} = EventHandler.handle_action_event(params, socket) assert reply[:status] == :error assert reply[:reason] == "unauthorized" end @@ -143,9 +143,9 @@ defmodule AshUI.LiveView.EventHandlerTest do params = %{"action_id" => "nonexistent_action", "data" => %{}} - assert {:reply, reply, socket} = EventHandler.handle_action_event(params, socket) + assert {:reply, reply, updated_socket} = EventHandler.handle_action_event(params, socket) assert reply[:status] == :error - assert socket.assigns[:flash] != nil + assert is_map(updated_socket.assigns[:flash]) end end @@ -166,7 +166,7 @@ defmodule AshUI.LiveView.EventHandlerTest do test "returns error for missing data field" do event_data = %{"target" => "input-1"} - assert {:error, {:missing_fields, ["target"]}} = + assert {:error, {:missing_fields, ["data"]}} = EventHandler.validate_event_data(event_data, "change") end end @@ -175,24 +175,28 @@ defmodule AshUI.LiveView.EventHandlerTest do test "assigns flash error message" do socket = build_socket() - assert {:noreply, socket} = EventHandler.handle_validation_error(:missing_target, socket) - assert socket.assigns[:flash][:error] != nil + assert {:noreply, updated_socket} = + EventHandler.handle_validation_error(:missing_target, socket) + + assert is_binary(updated_socket.assigns[:flash][:error]) end test "handles invalid type errors" do socket = build_socket() - assert {:noreply, socket} = + assert {:noreply, updated_socket} = EventHandler.handle_validation_error({:invalid_type, :got, :expected}, socket) - assert socket.assigns[:flash][:error] != nil + assert is_binary(updated_socket.assigns[:flash][:error]) end test "handles unknown errors" do socket = build_socket() - assert {:noreply, socket} = EventHandler.handle_validation_error(:unknown_error, socket) - assert socket.assigns[:flash][:error] != nil + assert {:noreply, updated_socket} = + EventHandler.handle_validation_error(:unknown_error, socket) + + assert is_binary(updated_socket.assigns[:flash][:error]) end end @@ -206,15 +210,15 @@ defmodule AshUI.LiveView.EventHandlerTest do } ) - assert {:ok, socket} = EventHandler.wire_handlers(socket) - assert socket.assigns[:ash_ui_handlers] != nil + assert {:ok, updated_socket} = EventHandler.wire_handlers(socket) + assert is_map(updated_socket.assigns[:ash_ui_handlers]) end test "handles socket with no bindings" do socket = build_socket(ash_ui_bindings: %{}) - assert {:ok, socket} = EventHandler.wire_handlers(socket) - assert socket.assigns[:ash_ui_handlers] != nil + assert {:ok, updated_socket} = EventHandler.wire_handlers(socket) + assert is_map(updated_socket.assigns[:ash_ui_handlers]) end end @@ -223,19 +227,20 @@ defmodule AshUI.LiveView.EventHandlerTest do socket = build_socket(ash_ui_bindings: %{}) params = %{"target" => "test"} - assert {:noreply, socket} = EventHandler.handle_event("ash_ui_change", params, socket) + assert {:noreply, _updated_socket} = + EventHandler.handle_event("ash_ui_change", params, socket) end test "handles unknown events gracefully" do socket = build_socket() - assert {:noreply, socket} = EventHandler.handle_event("unknown", %{}, socket) + assert {:noreply, _updated_socket} = EventHandler.handle_event("unknown", %{}, socket) end test "handles events with errors" do socket = build_socket(ash_ui_user: nil) - assert {:noreply, socket} = + assert {:noreply, _updated_socket} = EventHandler.handle_event("ash_ui_change", %{"target" => "test"}, socket) end end diff --git a/test/ash_ui/liveview/liveview_integration_test.exs b/test/ash_ui/liveview/liveview_integration_test.exs index 1191163f..7abc4334 100644 --- a/test/ash_ui/liveview/liveview_integration_test.exs +++ b/test/ash_ui/liveview/liveview_integration_test.exs @@ -31,7 +31,7 @@ defmodule AshUI.LiveView.IntegrationTest do setup do {:ok, _screen} = - AshUI.Domain.create(Screen, + AshUI.Data.create(Screen, attrs: %{ name: "test_screen", unified_dsl: %{"type" => "screen"} @@ -39,7 +39,7 @@ defmodule AshUI.LiveView.IntegrationTest do ) {:ok, _restricted_screen} = - AshUI.Domain.create(Screen, + AshUI.Data.create(Screen, attrs: %{ name: "restricted_screen", unified_dsl: %{"type" => "screen"} @@ -53,7 +53,7 @@ defmodule AshUI.LiveView.IntegrationTest do test "mounts screen successfully with valid user and screen" do socket = build_socket(current_user: build_admin()) - assert {:ok, socket} = Integration.mount_ui_screen(socket, :test_screen, %{}) + assert {:ok, _mounted_socket} = Integration.mount_ui_screen(socket, :test_screen, %{}) end test "returns error when no current user" do diff --git a/test/ash_ui/liveview/phase_4_integration_test.exs b/test/ash_ui/liveview/phase_4_integration_test.exs index de819164..ef318526 100644 --- a/test/ash_ui/liveview/phase_4_integration_test.exs +++ b/test/ash_ui/liveview/phase_4_integration_test.exs @@ -1,7 +1,6 @@ defmodule AshUI.LiveView.Phase4IntegrationTest do use ExUnit.Case, async: false - alias AshUI.LiveView.Integration alias AshUI.LiveView.UpdateIntegration alias AshUI.LiveView.EventHandler alias AshUI.LiveView.Lifecycle @@ -28,9 +27,8 @@ defmodule AshUI.LiveView.Phase4IntegrationTest do # Mount should succeed with valid user # Note: In actual implementation, would need to mock Ash.get - socket = socket - {:ok, socket} = Lifecycle.init_session(socket, :dashboard) - assert socket.assigns[:ash_ui_session].screen_id == :dashboard + {:ok, mounted_socket} = Lifecycle.init_session(socket, :dashboard) + assert mounted_socket.assigns[:ash_ui_session].screen_id == :dashboard end test "screen redirects on unauthorized access" do @@ -68,9 +66,8 @@ defmodule AshUI.LiveView.Phase4IntegrationTest do ) # Compilation errors should not crash the LiveView - 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 + assert {:error, errored_socket} = ErrorHandler.handle_compilation_error(:syntax_error, socket) + assert errored_socket.assigns[:ash_ui_error].type == :compilation end end @@ -87,8 +84,8 @@ defmodule AshUI.LiveView.Phase4IntegrationTest do params = %{"action_id" => "action1", "data" => %{"name" => "Test"}} # Action events should be handled - assert {:reply, reply, socket} = EventHandler.handle_action_event(params, socket) - assert reply != nil + assert {:reply, reply, _updated_socket} = EventHandler.handle_action_event(params, socket) + assert is_map(reply) end test "input changes update Ash resources" do @@ -101,7 +98,7 @@ defmodule AshUI.LiveView.Phase4IntegrationTest do params = %{"target" => "input-1", "value" => "new value"} # Value changes should be handled - assert {:noreply, socket} = EventHandler.handle_value_change(params, socket) + assert {:noreply, _updated_socket} = EventHandler.handle_value_change(params, socket) end test "action errors display feedback" do @@ -114,9 +111,9 @@ defmodule AshUI.LiveView.Phase4IntegrationTest do params = %{"action_id" => "nonexistent", "data" => %{}} # Missing actions should return error - assert {:reply, reply, socket} = EventHandler.handle_action_event(params, socket) + assert {:reply, reply, updated_socket} = EventHandler.handle_action_event(params, socket) assert reply[:status] == :error - assert socket.assigns[:flash][:error] != nil + assert is_binary(updated_socket.assigns[:flash][:error]) end test "event handlers receive correct parameters" do @@ -145,7 +142,8 @@ defmodule AshUI.LiveView.Phase4IntegrationTest do } # Resource changes should trigger updates - assert {:noreply, socket} = UpdateIntegration.handle_resource_change(notification, socket) + assert {:noreply, _updated_socket} = + UpdateIntegration.handle_resource_change(notification, socket) end test "multiple sessions don't interfere" do @@ -171,7 +169,7 @@ defmodule AshUI.LiveView.Phase4IntegrationTest do socket = build_socket() # Batch updates should apply all changes at once - assert {:noreply, socket} = + assert {:noreply, updated_socket} = UpdateIntegration.batch_updates(socket, fn socket -> socket |> Phoenix.Component.assign(:value1, 1) @@ -179,9 +177,9 @@ defmodule AshUI.LiveView.Phase4IntegrationTest do |> Phoenix.Component.assign(:value3, 3) end) - assert socket.assigns[:value1] == 1 - assert socket.assigns[:value2] == 2 - assert socket.assigns[:value3] == 3 + assert updated_socket.assigns[:value1] == 1 + assert updated_socket.assigns[:value2] == 2 + assert updated_socket.assigns[:value3] == 3 end test "subscriptions clean up on unmount" do @@ -191,7 +189,7 @@ defmodule AshUI.LiveView.Phase4IntegrationTest do |> elem(1) # Subscribe to some resources - {:ok, sub} = UpdateIntegration.subscribe(socket, User.Profile) + {:ok, _subscription} = UpdateIntegration.subscribe(socket, User.Profile) # Cleanup should remove subscriptions assert :ok = UpdateIntegration.cleanup_subscriptions(socket) @@ -202,7 +200,7 @@ defmodule AshUI.LiveView.Phase4IntegrationTest do test "full screen lifecycle" do # 1. Initialize session {:ok, socket} = Lifecycle.init_session(build_socket(), :dashboard) - assert socket.assigns[:ash_ui_session_id] != nil + assert is_binary(socket.assigns[:ash_ui_session_id]) # 2. Ensure isolation socket = Lifecycle.ensure_isolation(socket) @@ -214,11 +212,12 @@ defmodule AshUI.LiveView.Phase4IntegrationTest do # 4. Register lifecycle hook socket = Lifecycle.register_hook(socket, :on_update, fn socket -> socket end) - assert socket.assigns[:ash_ui_lifecycle_hooks][:on_update] != nil + assert [hook] = socket.assigns[:ash_ui_lifecycle_hooks][:on_update] + assert is_function(hook, 1) # 5. Execute hooks socket = Lifecycle.execute_hooks(socket, :on_update) - assert socket != nil + assert match?(%Phoenix.LiveView.Socket{}, socket) # 6. Cleanup assert :ok = Lifecycle.cleanup_session(socket) @@ -256,14 +255,15 @@ defmodule AshUI.LiveView.Phase4IntegrationTest do ) # 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) # 2. Resource change notification notification = %{type: :updated, resource: User.Profile, timestamp: DateTime.utc_now()} {:noreply, socket} = UpdateIntegration.handle_resource_change(notification, socket) # Socket should be updated - assert socket != nil + assert match?(%Phoenix.LiveView.Socket{}, socket) end end diff --git a/test/ash_ui/liveview/update_integration_test.exs b/test/ash_ui/liveview/update_integration_test.exs index 75e76130..f6295e98 100644 --- a/test/ash_ui/liveview/update_integration_test.exs +++ b/test/ash_ui/liveview/update_integration_test.exs @@ -25,7 +25,7 @@ defmodule AshUI.LiveView.UpdateIntegrationTest do socket = build_socket() assert {:ok, subscription} = UpdateIntegration.subscribe(socket, User.Profile) - assert subscription.id != nil + assert is_binary(subscription.id) assert subscription.resource == User.Profile end @@ -79,7 +79,8 @@ defmodule AshUI.LiveView.UpdateIntegrationTest do timestamp: DateTime.utc_now() } - assert {:noreply, socket} = UpdateIntegration.handle_resource_change(notification, socket) + assert {:noreply, _updated_socket} = + UpdateIntegration.handle_resource_change(notification, socket) end test "handles multiple binding changes" do @@ -100,7 +101,8 @@ defmodule AshUI.LiveView.UpdateIntegrationTest do timestamp: DateTime.utc_now() } - assert {:noreply, socket} = UpdateIntegration.handle_resource_change(notification, socket) + assert {:noreply, _updated_socket} = + UpdateIntegration.handle_resource_change(notification, socket) end test "handles created notifications" do @@ -117,7 +119,8 @@ defmodule AshUI.LiveView.UpdateIntegrationTest do timestamp: DateTime.utc_now() } - assert {:noreply, socket} = UpdateIntegration.handle_resource_change(notification, socket) + assert {:noreply, _updated_socket} = + UpdateIntegration.handle_resource_change(notification, socket) end test "handles destroyed notifications" do @@ -134,7 +137,8 @@ defmodule AshUI.LiveView.UpdateIntegrationTest do timestamp: DateTime.utc_now() } - assert {:noreply, socket} = UpdateIntegration.handle_resource_change(notification, socket) + assert {:noreply, _updated_socket} = + UpdateIntegration.handle_resource_change(notification, socket) end end @@ -142,28 +146,29 @@ defmodule AshUI.LiveView.UpdateIntegrationTest do test "routes created notifications" do socket = build_socket(ash_ui_screen: build_screen(), ash_ui_user: build_user()) - assert {:noreply, socket} = + assert {:noreply, _updated_socket} = UpdateIntegration.handle_notification({:created, %User.Profile{}}, socket) end test "routes updated notifications" do socket = build_socket(ash_ui_screen: build_screen(), ash_ui_user: build_user()) - assert {:noreply, socket} = + assert {:noreply, _updated_socket} = UpdateIntegration.handle_notification({:updated, %User.Profile{}}, socket) end test "routes destroyed notifications" do socket = build_socket(ash_ui_screen: build_screen(), ash_ui_user: build_user()) - assert {:noreply, socket} = + assert {:noreply, _updated_socket} = UpdateIntegration.handle_notification({:destroyed, %User.Profile{}}, socket) end test "handles unknown notification types gracefully" do socket = build_socket() - assert {:noreply, socket} = UpdateIntegration.handle_notification({:unknown, :data}, socket) + assert {:noreply, _updated_socket} = + UpdateIntegration.handle_notification({:unknown, :data}, socket) end end @@ -171,15 +176,15 @@ defmodule AshUI.LiveView.UpdateIntegrationTest do test "applies multiple updates in batch" do socket = build_socket() - assert {:noreply, socket} = + assert {:noreply, updated_socket} = UpdateIntegration.batch_updates(socket, fn socket -> socket |> Phoenix.Component.assign(:value1, 1) |> Phoenix.Component.assign(:value2, 2) end) - assert socket.assigns[:value1] == 1 - assert socket.assigns[:value2] == 2 + assert updated_socket.assigns[:value1] == 1 + assert updated_socket.assigns[:value2] == 2 end test "sets batch mode flag during updates" do @@ -201,7 +206,7 @@ defmodule AshUI.LiveView.UpdateIntegrationTest do ash_ui_params: %{} ) - assert {:noreply, socket} = UpdateIntegration.refresh_bindings(socket) + assert {:noreply, _updated_socket} = UpdateIntegration.refresh_bindings(socket) end end diff --git a/test/ash_ui/phase_8_integration_test.exs b/test/ash_ui/phase_8_integration_test.exs index 84e14a3d..20a5198b 100644 --- a/test/ash_ui/phase_8_integration_test.exs +++ b/test/ash_ui/phase_8_integration_test.exs @@ -4,7 +4,7 @@ defmodule AshUI.Phase8IntegrationTest do alias AshUI.Authorization.Runtime alias AshUI.Compiler alias AshUI.DSL.Builder - alias AshUI.Domain + alias AshUI.Data, as: Domain alias AshUI.LiveView.EventHandler alias AshUI.LiveView.Integration alias AshUI.Rendering.DesktopUIAdapter diff --git a/test/ash_ui/relationship_integration_test.exs b/test/ash_ui/relationship_integration_test.exs index 258e7740..47f00cae 100644 --- a/test/ash_ui/relationship_integration_test.exs +++ b/test/ash_ui/relationship_integration_test.exs @@ -10,7 +10,7 @@ defmodule AshUI.RelationshipIntegrationTest do setup do # Create a screen with multiple elements and bindings {:ok, screen} = - AshUI.Domain.create(Screen, + AshUI.Data.create(Screen, attrs: %{ name: "relationship_test_screen", unified_dsl: %{"type" => "screen"}, @@ -22,7 +22,7 @@ defmodule AshUI.RelationshipIntegrationTest do elements = Enum.map(1..3, fn i -> {:ok, element} = - AshUI.Domain.create(Element, + AshUI.Data.create(Element, attrs: %{ type: :text, props: %{"content" => "Text #{i}"}, @@ -39,7 +39,7 @@ defmodule AshUI.RelationshipIntegrationTest do Enum.flat_map(elements, fn element -> Enum.map(1..2, fn j -> {:ok, binding} = - AshUI.Domain.create(Binding, + AshUI.Data.create(Binding, attrs: %{ source: %{"field" => "field_#{j}"}, target: "target_#{j}", @@ -59,7 +59,7 @@ defmodule AshUI.RelationshipIntegrationTest do describe "Loading screen with preloaded elements" do test "loads all associated elements", %{screen: screen} do screen_with_elements = - AshUI.Domain.read_one!(Screen, + AshUI.Data.read_one!(Screen, filter: [id: screen.id], load: [:elements] ) @@ -74,7 +74,7 @@ defmodule AshUI.RelationshipIntegrationTest do test "loads elements in correct position order", %{screen: screen} do screen_with_elements = - AshUI.Domain.read_one!(Screen, + AshUI.Data.read_one!(Screen, filter: [id: screen.id], load: [elements: Ash.Query.sort(Element, position: :asc)] ) @@ -87,7 +87,7 @@ defmodule AshUI.RelationshipIntegrationTest do describe "Loading element with preloaded bindings" do test "loads all associated bindings for an element", %{elements: [element | _]} do element_with_bindings = - AshUI.Domain.read_one!(Element, + AshUI.Data.read_one!(Element, filter: [id: element.id], load: [:bindings] ) @@ -103,14 +103,14 @@ defmodule AshUI.RelationshipIntegrationTest do describe "Querying elements by screen association" do test "filters elements by screen_id", %{screen: screen} do - elements = AshUI.Domain.read!(Element, filter: [screen_id: screen.id]) + elements = AshUI.Data.read!(Element, filter: [screen_id: screen.id]) assert length(elements) == 3 end test "combines screen filter with other conditions", %{screen: screen} do elements = - AshUI.Domain.read!(Element, + AshUI.Data.read!(Element, filter: [ screen_id: screen.id, position: [greater_than: 1] @@ -126,13 +126,13 @@ defmodule AshUI.RelationshipIntegrationTest do describe "Querying bindings by element or screen associations" do test "filters bindings by element_id", %{elements: [element | _]} do - bindings = AshUI.Domain.read!(Binding, filter: [element_id: element.id]) + bindings = AshUI.Data.read!(Binding, filter: [element_id: element.id]) assert length(bindings) == 2 end test "filters bindings by screen_id", %{screen: screen} do - bindings = AshUI.Domain.read!(Binding, filter: [screen_id: screen.id]) + bindings = AshUI.Data.read!(Binding, filter: [screen_id: screen.id]) # Each element has 2 bindings, 3 elements = 6 bindings assert length(bindings) == 6 @@ -143,7 +143,7 @@ defmodule AshUI.RelationshipIntegrationTest do elements: [element | _] } do bindings = - AshUI.Domain.read!(Binding, + AshUI.Data.read!(Binding, filter: [ element_id: element.id, screen_id: screen.id @@ -157,7 +157,7 @@ defmodule AshUI.RelationshipIntegrationTest do describe "Nested relationship loading" do test "loads screen with elements and their bindings", %{screen: screen} do screen_with_all = - AshUI.Domain.read_one!(Screen, + AshUI.Data.read_one!(Screen, filter: [id: screen.id], load: [elements: [:bindings]] ) @@ -177,12 +177,12 @@ defmodule AshUI.RelationshipIntegrationTest do describe "Cross-resource queries" do test "finds all bindings for screen's elements", %{screen: screen} do # Get all element IDs for the screen - elements = AshUI.Domain.read!(Element, filter: [screen_id: screen.id]) + elements = AshUI.Data.read!(Element, filter: [screen_id: screen.id]) element_ids = Enum.map(elements, & &1.id) # Find all bindings for those elements bindings = - AshUI.Domain.read!(Binding, filter: [element_id: [in: element_ids]]) + AshUI.Data.read!(Binding, filter: [element_id: [in: element_ids]]) assert length(bindings) == 6 end diff --git a/test/ash_ui/resources/binding_test.exs b/test/ash_ui/resources/binding_test.exs index 6334e6e5..31f47003 100644 --- a/test/ash_ui/resources/binding_test.exs +++ b/test/ash_ui/resources/binding_test.exs @@ -9,7 +9,7 @@ defmodule AshUI.Resources.BindingTest do setup do {:ok, screen} = - AshUI.Domain.create(Screen, + AshUI.Data.create(Screen, attrs: %{ name: "binding_test_screen", unified_dsl: %{"type" => "screen"}, @@ -18,7 +18,7 @@ defmodule AshUI.Resources.BindingTest do ) {:ok, element} = - AshUI.Domain.create(Element, + AshUI.Data.create(Element, attrs: %{ type: :textinput, props: %{}, @@ -43,7 +43,7 @@ defmodule AshUI.Resources.BindingTest do screen_id: screen.id } - assert {:ok, binding} = AshUI.Domain.create(Binding, attrs: attrs) + assert {:ok, binding} = AshUI.Data.create(Binding, attrs: attrs) assert binding.source == %{"resource" => "User", "field" => "name"} assert binding.target == "value" assert binding.binding_type == :value @@ -60,7 +60,7 @@ defmodule AshUI.Resources.BindingTest do screen_id: screen.id } - assert {:ok, binding} = AshUI.Domain.create(Binding, attrs: attrs) + assert {:ok, binding} = AshUI.Data.create(Binding, attrs: attrs) assert binding.binding_type == :list end @@ -73,7 +73,7 @@ defmodule AshUI.Resources.BindingTest do screen_id: screen.id } - assert {:ok, binding} = AshUI.Domain.create(Binding, attrs: attrs) + assert {:ok, binding} = AshUI.Data.create(Binding, attrs: attrs) assert binding.binding_type == :action end @@ -86,10 +86,10 @@ defmodule AshUI.Resources.BindingTest do screen_id: screen.id } - {:ok, binding} = AshUI.Domain.create(Binding, attrs: attrs) + {:ok, binding} = AshUI.Data.create(Binding, attrs: attrs) {:ok, updated} = - AshUI.Domain.update(binding, + AshUI.Data.update(binding, attrs: %{ transform: %{"function" => "uppercase"} } @@ -111,12 +111,12 @@ defmodule AshUI.Resources.BindingTest do screen_id: screen.id } - AshUI.Domain.create(Binding, attrs: attrs) + AshUI.Data.create(Binding, attrs: attrs) end) # Load element with bindings element_with_bindings = - AshUI.Domain.read_one!(Element, + AshUI.Data.read_one!(Element, filter: [id: element.id], load: [:bindings] ) @@ -139,16 +139,16 @@ defmodule AshUI.Resources.BindingTest do screen_id: screen.id } - {:ok, binding} = AshUI.Domain.create(Binding, attrs: attrs) + {:ok, binding} = AshUI.Data.create(Binding, attrs: attrs) # Delete screen - :ok = AshUI.Domain.destroy(screen) + :ok = AshUI.Data.destroy(screen) # Element should be deleted - assert [] = AshUI.Domain.read!(Element, filter: [id: element.id]) + assert [] = AshUI.Data.read!(Element, filter: [id: element.id]) # Binding should be deleted - assert [] = AshUI.Domain.read!(Binding, filter: [id: binding.id]) + assert [] = AshUI.Data.read!(Binding, filter: [id: binding.id]) end end end diff --git a/test/ash_ui/resources/element_test.exs b/test/ash_ui/resources/element_test.exs index 8bf71b60..195eafbe 100644 --- a/test/ash_ui/resources/element_test.exs +++ b/test/ash_ui/resources/element_test.exs @@ -9,7 +9,7 @@ defmodule AshUI.Resources.ElementTest do describe "Element CRUD operations" do setup do {:ok, screen} = - AshUI.Domain.create(Screen, + AshUI.Data.create(Screen, attrs: %{ name: "element_test_screen", unified_dsl: %{"type" => "screen"}, @@ -28,7 +28,7 @@ defmodule AshUI.Resources.ElementTest do position: 1 } - assert {:ok, element} = AshUI.Domain.create(Element, attrs: attrs) + assert {:ok, element} = AshUI.Data.create(Element, attrs: attrs) assert element.type == :text assert element.props == %{"content" => "Hello World"} assert element.screen_id == screen.id @@ -43,8 +43,8 @@ defmodule AshUI.Resources.ElementTest do position: 1 } - {:ok, element} = AshUI.Domain.create(Element, attrs: attrs) - {:ok, updated} = AshUI.Domain.update(element, attrs: %{props: %{"content" => "Updated"}}) + {:ok, element} = AshUI.Data.create(Element, attrs: attrs) + {:ok, updated} = AshUI.Data.update(element, attrs: %{props: %{"content" => "Updated"}}) assert updated.props == %{"content" => "Updated"} end @@ -57,10 +57,10 @@ defmodule AshUI.Resources.ElementTest do position: 1 } - {:ok, element} = AshUI.Domain.create(Element, attrs: attrs) - assert :ok = AshUI.Domain.destroy(element) + {:ok, element} = AshUI.Data.create(Element, attrs: attrs) + assert :ok = AshUI.Data.destroy(element) - assert [] = AshUI.Domain.read!(Element, filter: [id: element.id]) + assert [] = AshUI.Data.read!(Element, filter: [id: element.id]) end end @@ -76,7 +76,7 @@ defmodule AshUI.Resources.ElementTest do } # Type should be accepted (validation happens at DSL level) - assert {:ok, _element} = AshUI.Domain.create(Element, attrs: attrs) + assert {:ok, _element} = AshUI.Data.create(Element, attrs: attrs) end) end end @@ -84,7 +84,7 @@ defmodule AshUI.Resources.ElementTest do describe "Screen association" do test "loads elements through screen relationship" do {:ok, screen} = - AshUI.Domain.create(Screen, + AshUI.Data.create(Screen, attrs: %{ name: "association_test_screen", unified_dsl: %{"type" => "screen"}, @@ -101,12 +101,12 @@ defmodule AshUI.Resources.ElementTest do position: i } - AshUI.Domain.create(Element, attrs: attrs) + AshUI.Data.create(Element, attrs: attrs) end) # Load screen with elements screen_with_elements = - AshUI.Domain.read_one!(Screen, + AshUI.Data.read_one!(Screen, filter: [id: screen.id], load: [:elements] ) diff --git a/test/ash_ui/resources/screen_test.exs b/test/ash_ui/resources/screen_test.exs index 96f02c99..c5ecf108 100644 --- a/test/ash_ui/resources/screen_test.exs +++ b/test/ash_ui/resources/screen_test.exs @@ -2,8 +2,6 @@ defmodule AshUI.Resources.ScreenTest do use AshUI.DataCase, async: false alias AshUI.Resources.Screen - alias AshUI.Resources.Element - alias AshUI.Resources.Binding @moduletag :conformance @@ -19,7 +17,7 @@ defmodule AshUI.Resources.ScreenTest do route: "/test" } - assert {:ok, screen} = AshUI.Domain.create(Screen, attrs: attrs) + assert {:ok, screen} = AshUI.Data.create(Screen, attrs: attrs) assert screen.name == "test_screen" assert screen.layout == :row assert screen.route == "/test" @@ -37,10 +35,10 @@ defmodule AshUI.Resources.ScreenTest do layout: :row } - AshUI.Domain.create(Screen, attrs: attrs) + AshUI.Data.create(Screen, attrs: attrs) end) - screens = AshUI.Domain.read!(Screen) + screens = AshUI.Data.read!(Screen) assert length(screens) >= 2 end @@ -51,8 +49,8 @@ defmodule AshUI.Resources.ScreenTest do layout: :column } - {:ok, screen} = AshUI.Domain.create(Screen, attrs: attrs) - {:ok, updated} = AshUI.Domain.update(screen, attrs: %{layout: :grid}) + {:ok, screen} = AshUI.Data.create(Screen, attrs: attrs) + {:ok, updated} = AshUI.Data.update(screen, attrs: %{layout: :grid}) assert updated.layout == :grid assert updated.version == 2 @@ -65,10 +63,10 @@ defmodule AshUI.Resources.ScreenTest do layout: :row } - {:ok, screen} = AshUI.Domain.create(Screen, attrs: attrs) - assert :ok = AshUI.Domain.destroy(screen) + {:ok, screen} = AshUI.Data.create(Screen, attrs: attrs) + assert :ok = AshUI.Data.destroy(screen) - assert [] = AshUI.Domain.read!(Screen, filter: [name: "destroy_test"]) + assert [] = AshUI.Data.read!(Screen, filter: [name: "destroy_test"]) end end @@ -80,9 +78,9 @@ defmodule AshUI.Resources.ScreenTest do layout: :row } - {:ok, _screen} = AshUI.Domain.create(Screen, attrs: attrs) + {:ok, _screen} = AshUI.Data.create(Screen, attrs: attrs) - assert {:error, error} = AshUI.Domain.create(Screen, attrs: attrs) + assert {:error, error} = AshUI.Data.create(Screen, attrs: attrs) assert Exception.message(error) =~ "constraint error" end end diff --git a/test/ash_ui/runtime/action_binding_test.exs b/test/ash_ui/runtime/action_binding_test.exs index 0125471f..63bf92b4 100644 --- a/test/ash_ui/runtime/action_binding_test.exs +++ b/test/ash_ui/runtime/action_binding_test.exs @@ -21,18 +21,18 @@ defmodule AshUI.Runtime.ActionBindingTest do %{binding: binding, context: context} end - test "executes action with event data" do + test "executes action with 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 {:ok, result} = ActionBinding.execute_action(binding, event_data, context) assert result.status == :ok assert result.data != nil end - test "returns error for unauthorized action" do + test "returns error for unauthorized action", %{binding: binding} do unauthorized_context = %{user_id: nil, params: %{}, assigns: %{}} - assert {:error, _reason} = ActionBinding.execute_action(@binding, %{}, unauthorized_context) + assert {:error, _reason} = ActionBinding.execute_action(binding, %{}, unauthorized_context) end end diff --git a/test/ash_ui/runtime/list_binding_test.exs b/test/ash_ui/runtime/list_binding_test.exs index 492d5c25..b0646ef5 100644 --- a/test/ash_ui/runtime/list_binding_test.exs +++ b/test/ash_ui/runtime/list_binding_test.exs @@ -17,8 +17,8 @@ defmodule AshUI.Runtime.ListBindingTest do %{binding: binding, context: context} end - test "loads collection with pagination" do - assert {:ok, result} = ListBinding.load_collection(@binding, @context, page: 1, page_size: 20) + test "loads collection with pagination", %{binding: binding, context: context} do + assert {:ok, result} = ListBinding.load_collection(binding, context, page: 1, page_size: 20) assert is_list(result.items) assert result.total > 0 @@ -26,8 +26,8 @@ defmodule AshUI.Runtime.ListBindingTest do assert result.page_size == 20 end - test "handles empty collections" do - assert {:ok, result} = ListBinding.load_collection(@binding, @context, page: 999, page_size: 20) + test "handles empty collections", %{binding: binding, context: context} do + assert {:ok, result} = ListBinding.load_collection(binding, context, page: 999, page_size: 20) assert result.items == [] assert result.has_next == false @@ -52,30 +52,33 @@ defmodule AshUI.Runtime.ListBindingTest do %{binding: binding, context: context, socket: socket} end - test "handles insert changes" do + test "handles insert changes", %{binding: binding, context: context, socket: socket} 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) + ListBinding.handle_collection_change(binding, :insert, change_data, socket, context) + assert updated_socket.assigns != %{} assert should_update == true end - test "handles update changes" do + 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) + ListBinding.handle_collection_change(binding, :update, change_data, socket, context) + assert updated_socket.assigns != %{} assert should_update == true end - test "handles delete changes" do + 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) + ListBinding.handle_collection_change(binding, :delete, change_data, socket, context) + assert updated_socket.assigns != %{} assert should_update == true end end diff --git a/test/support/data_case.ex b/test/support/data_case.ex index 725acf41..1f370c9f 100644 --- a/test/support/data_case.ex +++ b/test/support/data_case.ex @@ -9,7 +9,7 @@ defmodule AshUI.DataCase do using do quote do alias AshUI.Repo - alias AshUI.Domain + alias AshUI.Data, as: Domain import Ecto.Query import AshUI.DataCase