From c50127006eece18b086d67b76b5ae28ad0cf91dc Mon Sep 17 00:00:00 2001 From: Pascal Charbonneau Date: Sat, 21 Mar 2026 10:54:50 -0600 Subject: [PATCH] Wire LiveView reactivity to real Ash notifications --- README.md | 2 +- guides/user/UG-0003-data-binding.md | 4 +- lib/ash_ui/application.ex | 1 + lib/ash_ui/liveview/update_integration.ex | 19 +++-- lib/ash_ui/notifications.ex | 66 ++++++++++++++++ lib/ash_ui/resources/binding.ex | 12 +++ lib/ash_ui/resources/element.ex | 12 +++ lib/ash_ui/resources/screen.ex | 12 +++ specs/contracts/binding_contract.md | 2 +- specs/planning/README.md | 2 +- ...hase-03-data-binding-and-signal-mapping.md | 46 ++++++------ ...ase-04-runtime-and-liveview-integration.md | 36 ++++----- ...05-authorization-and-policy-enforcement.md | 18 ++--- specs/resources/ui_binding.md | 2 +- .../liveview/phase_4_integration_test.exs | 33 ++++++-- .../liveview/update_integration_test.exs | 75 ++++++++++++++++++- test/support/runtime_test_resources.ex | 48 ++++++++++++ 17 files changed, 318 insertions(+), 72 deletions(-) create mode 100644 lib/ash_ui/notifications.ex diff --git a/README.md b/README.md index dce72879..f144baf4 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ Key starting points: ## Current Status -Phase 8 governance work is complete, but the repo is still closing feature gaps in earlier phases. The current implementation is strongest in resource storage, compilation, runtime wiring, observability, and governance, while real Ash-backed binding execution and full external renderer integration remain open in reopened Phase 1, 3, 4, 5, and 7 workstreams. +Phase 8 governance work is complete, and the runtime stack now includes real Ash-backed binding execution, authorization, and LiveView reactivity. The main remaining open workstreams are the legacy resource DSL items in Phase 1 and the optional external renderer package integration tracked in Phase 7. ## Development Notes diff --git a/guides/user/UG-0003-data-binding.md b/guides/user/UG-0003-data-binding.md index 899ac1c9..c73b40a0 100644 --- a/guides/user/UG-0003-data-binding.md +++ b/guides/user/UG-0003-data-binding.md @@ -146,7 +146,7 @@ context = %{user_id: "user-1", params: %{}, assigns: %{}} AshUI.Runtime.BidirectionalBinding.write_binding(binding, "Updated Name", socket, context) ``` -The current implementation returns mock update results, but the call shape is the one LiveView integration already uses. +Successful writes now return the real Ash update result metadata, including the updated record and resolved field value. ## Event Handling in LiveView @@ -190,7 +190,7 @@ Your `source` is not a map in the expected shape. ### Empty or placeholder values -The runtime currently resolves resource data through placeholder loaders in some paths. Verify the binding shape first before assuming the renderer is broken. +The runtime now resolves binding data through real Ash reads. If you see empty values, check authorization, record identifiers, and transformation defaults before assuming the renderer is broken. ### Writes fail with forbidden errors diff --git a/lib/ash_ui/application.ex b/lib/ash_ui/application.ex index 99e2ebb4..1d2a4346 100644 --- a/lib/ash_ui/application.ex +++ b/lib/ash_ui/application.ex @@ -16,6 +16,7 @@ defmodule AshUI.Application do AshUI.Authorization.Runtime.init_cache() children = [ + {Phoenix.PubSub, name: AshUI.PubSub}, AshUI.Telemetry, AshUI.Repo, AshUI.Rendering.Registry diff --git a/lib/ash_ui/liveview/update_integration.ex b/lib/ash_ui/liveview/update_integration.ex index e9c3d25a..d9d45227 100644 --- a/lib/ash_ui/liveview/update_integration.ex +++ b/lib/ash_ui/liveview/update_integration.ex @@ -9,6 +9,7 @@ defmodule AshUI.LiveView.UpdateIntegration do require Logger alias AshUI.LiveView.Integration + alias AshUI.Notifications alias AshUI.Runtime.BindingEvaluator alias AshUI.Runtime.ResourceAccess @@ -105,7 +106,8 @@ defmodule AshUI.LiveView.UpdateIntegration do This should be called from LiveView's `handle_info/2` callback. """ - @spec handle_resource_change(map(), Phoenix.LiveView.Socket.t()) :: update_result() + @spec handle_resource_change(map() | Ash.Notifier.Notification.t(), Phoenix.LiveView.Socket.t()) :: + update_result() def handle_resource_change(notification, socket) do bindings = socket.assigns[:ash_ui_bindings] || %{} @@ -136,7 +138,11 @@ defmodule AshUI.LiveView.UpdateIntegration do @doc """ Handles subscription messages from Ash.Notifier. """ - @spec handle_notification(tuple(), Phoenix.LiveView.Socket.t()) :: update_result() + @spec handle_notification(term(), Phoenix.LiveView.Socket.t()) :: update_result() + def handle_notification(%Ash.Notifier.Notification{} = notification, socket) do + handle_resource_change(notification, socket) + end + def handle_notification({:created, resource}, socket) do handle_resource_change(%{type: :created, resource: resource, timestamp: DateTime.utc_now()}, socket) end @@ -220,13 +226,9 @@ defmodule AshUI.LiveView.UpdateIntegration do "#{inspect(resource)}_#{action}_#{:erlang.phash2(filter)}" end - defp subscribe_to_resource(_resource, _subscription) do - # Ash.Notifier integration is still an external dependency. - # We track subscriptions per LiveView session so the reactivity pipeline - # behaves correctly once real notifications are delivered. - :ok - end + defp subscribe_to_resource(resource, _subscription), do: Notifications.subscribe(resource) + defp unsubscribe_from_resource(%{resource: resource}), do: Notifications.unsubscribe(resource) defp unsubscribe_from_resource(_subscription), do: :ok defp binding_resources(bindings, socket) do @@ -449,6 +451,7 @@ defmodule AshUI.LiveView.UpdateIntegration do end) end + defp get_notification_resource(%Ash.Notifier.Notification{resource: resource}), do: resource defp get_notification_resource(%{resource: %{__struct__: resource}}), do: resource defp get_notification_resource(%{resource: resource}) when is_atom(resource), do: resource defp get_notification_resource(_), do: nil diff --git a/lib/ash_ui/notifications.ex b/lib/ash_ui/notifications.ex new file mode 100644 index 00000000..a3cd2796 --- /dev/null +++ b/lib/ash_ui/notifications.ex @@ -0,0 +1,66 @@ +defmodule AshUI.Notifications do + @moduledoc """ + PubSub bridge for Ash resource notifications used by LiveView reactivity. + + Resources publish Ash notifications through `Ash.Notifier.PubSub` to the + local `AshUI.PubSub` server, and runtime integrations subscribe to the + per-resource change topics exposed here. + """ + + @doc """ + Broadcasts a notification payload on the given topic and event. + """ + @spec broadcast(String.t(), String.t(), term()) :: :ok | {:error, term()} + def broadcast(topic, _event, payload) do + Phoenix.PubSub.broadcast(AshUI.PubSub, topic, payload) + end + + @doc """ + Subscribes the current process to change notifications for a resource. + """ + @spec subscribe(module()) :: :ok + def subscribe(resource) do + case resource_topic(resource) do + {:ok, topic} -> Phoenix.PubSub.subscribe(AshUI.PubSub, topic) + {:error, _reason} -> :ok + end + end + + @doc """ + Unsubscribes the current process from change notifications for a resource. + """ + @spec unsubscribe(module()) :: :ok + def unsubscribe(resource) do + case resource_topic(resource) do + {:ok, topic} -> Phoenix.PubSub.unsubscribe(AshUI.PubSub, topic) + {:error, _reason} -> :ok + end + end + + @doc """ + Returns the resource change topic for an Ash resource module. + """ + @spec resource_topic(module()) :: {:ok, String.t()} | {:error, :unsupported_resource} + def resource_topic(resource) when is_atom(resource) do + resource + |> resource_path() + |> case do + nil -> {:error, :unsupported_resource} + path -> {:ok, "ash_ui:resource:#{path}:changes"} + end + end + + def resource_topic(_resource), do: {:error, :unsupported_resource} + + defp resource_path(resource) do + resource + |> Atom.to_string() + |> String.trim_leading("Elixir.") + |> case do + "" -> nil + name -> String.replace(name, ".", ":") + end + rescue + _ -> nil + end +end diff --git a/lib/ash_ui/resources/binding.ex b/lib/ash_ui/resources/binding.ex index ff9e8be8..1db0acd8 100644 --- a/lib/ash_ui/resources/binding.ex +++ b/lib/ash_ui/resources/binding.ex @@ -5,9 +5,12 @@ defmodule AshUI.Resources.Binding do Bindings connect UI elements to Ash resource data. """ + @resource_topic_prefix "ash_ui:resource:AshUI:Resources:Binding" + use Ash.Resource, domain: AshUI.Domain, authorizers: [Ash.Policy.Authorizer], + notifiers: [Ash.Notifier.PubSub], data_layer: AshPostgres.DataLayer postgres do @@ -15,6 +18,15 @@ defmodule AshUI.Resources.Binding do repo AshUI.Repo end + pub_sub do + module AshUI.Notifications + prefix @resource_topic_prefix + + publish :create, "changes" + publish :update, "changes" + publish :destroy, "changes" + end + attributes do uuid_primary_key :id attribute :source, :map, allow_nil?: false, default: %{} diff --git a/lib/ash_ui/resources/element.ex b/lib/ash_ui/resources/element.ex index 46eb3e2f..b8ef59e6 100644 --- a/lib/ash_ui/resources/element.ex +++ b/lib/ash_ui/resources/element.ex @@ -5,9 +5,12 @@ defmodule AshUI.Resources.Element do Elements are atomic UI components (widgets) like buttons, inputs, text, etc. """ + @resource_topic_prefix "ash_ui:resource:AshUI:Resources:Element" + use Ash.Resource, domain: AshUI.Domain, authorizers: [Ash.Policy.Authorizer], + notifiers: [Ash.Notifier.PubSub], data_layer: AshPostgres.DataLayer postgres do @@ -15,6 +18,15 @@ defmodule AshUI.Resources.Element do repo AshUI.Repo end + pub_sub do + module AshUI.Notifications + prefix @resource_topic_prefix + + publish :create, "changes" + publish :update, "changes" + publish :destroy, "changes" + end + attributes do uuid_primary_key :id attribute :type, :atom, allow_nil?: false diff --git a/lib/ash_ui/resources/screen.ex b/lib/ash_ui/resources/screen.ex index 8ee25f06..74eced17 100644 --- a/lib/ash_ui/resources/screen.ex +++ b/lib/ash_ui/resources/screen.ex @@ -3,9 +3,12 @@ defmodule AshUI.Resources.Screen do Ash Resource for storing unified-ui screen definitions. """ + @resource_topic_prefix "ash_ui:resource:AshUI:Resources:Screen" + use Ash.Resource, domain: AshUI.Domain, authorizers: [Ash.Policy.Authorizer], + notifiers: [Ash.Notifier.PubSub], data_layer: AshPostgres.DataLayer postgres do @@ -13,6 +16,15 @@ defmodule AshUI.Resources.Screen do repo AshUI.Repo end + pub_sub do + module AshUI.Notifications + prefix @resource_topic_prefix + + publish :create, "changes" + publish :update, "changes" + publish :destroy, "changes" + end + attributes do uuid_primary_key :id attribute :name, :string, allow_nil?: false diff --git a/specs/contracts/binding_contract.md b/specs/contracts/binding_contract.md index d324dc59..4f62e54b 100644 --- a/specs/contracts/binding_contract.md +++ b/specs/contracts/binding_contract.md @@ -151,7 +151,7 @@ Bindings MUST emit telemetry events for evaluation, update, and error flows. ## Implementation Note -The repository currently exposes the binding APIs and telemetry surface described here, but some read, write, list, and action paths are still backed by placeholder implementations. This contract describes the intended real Ash-backed behavior that the reopened Phase 3 and Phase 4 work will complete. +The repository now exposes the binding APIs and telemetry surface described here through real Ash-backed read, write, list, and action paths. This contract describes the behavior that the runtime and LiveView integration enforce today. ## Traceability diff --git a/specs/planning/README.md b/specs/planning/README.md index 07d60b75..4841b6bd 100644 --- a/specs/planning/README.md +++ b/specs/planning/README.md @@ -40,4 +40,4 @@ The plan aligns to: ## Status Note -The phase files are historical planning documents, not a guarantee that every checked item is production-backed today. After the RFC-0002 re-baseline, some earlier phases have been reopened to reflect remaining gaps around resource-level authorization, real Ash-backed binding execution, and external renderer integration. +The phase files are historical planning documents, not a guarantee that every checked item is production-backed today. After the RFC-0002 re-baseline, the remaining open implementation gaps are concentrated in the legacy resource DSL work from Phase 1 and the optional external renderer packages tracked in Phase 7. diff --git a/specs/planning/phase-03-data-binding-and-signal-mapping.md b/specs/planning/phase-03-data-binding-and-signal-mapping.md index 642d4663..2f2193f7 100644 --- a/specs/planning/phase-03-data-binding-and-signal-mapping.md +++ b/specs/planning/phase-03-data-binding-and-signal-mapping.md @@ -15,10 +15,10 @@ Back to index: [README](./README.md) - Bidirectional bindings support read and write operations - Action bindings trigger Ash actions on UI events -[ ] 3 Phase 3 - Data Binding and Signal Mapping +[X] 3 Phase 3 - Data Binding and Signal Mapping Implement reactive data binding from Ash resources to UI elements through unified-ui signal format. - Status note: the runtime APIs, data structures, and much of the surrounding test coverage exist, but several source-resolution, write, list, and action paths are still backed by placeholders instead of real Ash calls. + Status note: runtime bindings now resolve reads, writes, list loading, and actions through real Ash-backed helpers with authorization-aware access and end-to-end coverage. [X] 3.1 Section - Binding Evaluation Implement runtime evaluation of bindings against Ash resource data. @@ -31,13 +31,13 @@ Back to index: [README](./README.md) [X] 3.1.1.3 Subtask - Return `{:ok, value}` or `{:error, reason}` [X] 3.1.1.4 Subtask - Cache evaluated values for performance - [ ] 3.1.2 Task - Implement source resolution against real Ash resources + [X] 3.1.2 Task - Implement source resolution against real Ash resources Resolve structured binding sources to Ash resource attributes and relationships. [X] 3.1.2.1 Subtask - Parse structured binding sources - [ ] 3.1.2.2 Subtask - Load resource using real Ash reads with proper authorization - [ ] 3.1.2.3 Subtask - Extract attribute value from loaded resource - [ ] 3.1.2.4 Subtask - Handle relationship traversal against loaded data + [X] 3.1.2.2 Subtask - Load resource using real Ash reads with proper authorization + [X] 3.1.2.3 Subtask - Extract attribute value from loaded resource + [X] 3.1.2.4 Subtask - Handle relationship traversal against loaded data [X] 3.1.3 Task - Implement transformation application Apply transformation rules to resolved values. @@ -47,7 +47,7 @@ Back to index: [README](./README.md) [X] 3.1.3.3 Subtask - Apply `default` transformations when source is nil [X] 3.1.3.4 Subtask - Apply `validate` transformations and return errors - [ ] 3.2 Section - Bidirectional Value Bindings + [X] 3.2 Section - Bidirectional Value Bindings Implement two-way data binding for `:value` type bindings. [X] 3.2.1 Task - Implement read direction @@ -58,15 +58,15 @@ Back to index: [README](./README.md) [X] 3.2.1.3 Subtask - Update LiveView assigns on value change [X] 3.2.1.4 Subtask - Handle loading and error states - [ ] 3.2.2 Task - Implement write direction + [X] 3.2.2 Task - Implement write direction Flow data from UI elements to Ash resources. [X] 3.2.2.1 Subtask - Capture user input events from LiveView [X] 3.2.2.2 Subtask - Validate input data before writing - [ ] 3.2.2.3 Subtask - Call real Ash update actions with new value - [ ] 3.2.2.4 Subtask - Handle update errors and display to user + [X] 3.2.2.3 Subtask - Call real Ash update actions with new value + [X] 3.2.2.4 Subtask - Handle update errors and display to user - [ ] 3.2.3 Task - Implement conflict resolution + [X] 3.2.3 Task - Implement conflict resolution Handle concurrent updates to shared data. [X] 3.2.3.1 Subtask - Detect stale data with optimistic locking @@ -74,18 +74,18 @@ Back to index: [README](./README.md) [X] 3.2.3.3 Subtask - Present conflict UI to user for resolution [X] 3.2.3.4 Subtask - Emit conflict telemetry events - [ ] 3.3 Section - List Bindings + [X] 3.3 Section - List Bindings Implement collection binding for `:list` type bindings. - [ ] 3.3.1 Task - Implement collection loading + [X] 3.3.1 Task - Implement collection loading Load and bind collections of resources to UI elements. [X] 3.3.1.1 Subtask - Resolve collection source path - [ ] 3.3.1.2 Subtask - Use real Ash reads to load collection - [ ] 3.3.1.3 Subtask - Apply pagination and filtering + [X] 3.3.1.2 Subtask - Use real Ash reads to load collection + [X] 3.3.1.3 Subtask - Apply pagination and filtering [X] 3.3.1.4 Subtask - Handle empty collections - [ ] 3.3.2 Task - Implement collection reactivity + [X] 3.3.2 Task - Implement collection reactivity Update UI when collection data changes. [X] 3.3.2.1 Subtask - Subscribe to collection changes @@ -93,16 +93,16 @@ Back to index: [README](./README.md) [X] 3.3.2.3 Subtask - Handle insert, update, delete operations [X] 3.3.2.4 Subtask - Maintain scroll position during updates - [ ] 3.4 Section - Action Bindings + [X] 3.4 Section - Action Bindings Implement event-driven binding for `:action` type bindings. - [ ] 3.4.1 Task - Implement action execution + [X] 3.4.1 Task - Implement action execution Execute Ash actions in response to UI events. [X] 3.4.1.1 Subtask - Parse action source - [ ] 3.4.1.2 Subtask - Call real Ash actions with event data - [ ] 3.4.1.3 Subtask - Check authorization before execution - [ ] 3.4.1.4 Subtask - Return action result to UI + [X] 3.4.1.2 Subtask - Call real Ash actions with event data + [X] 3.4.1.3 Subtask - Check authorization before execution + [X] 3.4.1.4 Subtask - Return action result to UI [X] 3.4.2 Task - Implement action event wiring Connect UI events to action bindings. @@ -123,7 +123,7 @@ Back to index: [README](./README.md) [X] 3.5.1.3 Subtask - Implement signal creation helpers [X] 3.5.1.4 Subtask - Add signal validation - [ ] 3.5.2 Task - Convert to Jido.Signal format + [X] 3.5.2 Task - Convert to Jido.Signal format Ensure signals are compatible with unified signal transport. [X] 3.5.2.1 Subtask - Wrap Ash signals in Jido.Signal structure @@ -131,7 +131,7 @@ Back to index: [README](./README.md) [X] 3.5.2.3 Subtask - Include required CloudEvents fields (id, source, type) [X] 3.5.2.4 Subtask - Add signal metadata for tracing - [ ] 3.6 Section - Phase 3 Integration Tests + [X] 3.6 Section - Phase 3 Integration Tests Validate binding evaluation and reactivity end-to-end. [X] 3.6.1 Task - Value binding integration scenarios diff --git a/specs/planning/phase-04-runtime-and-liveview-integration.md b/specs/planning/phase-04-runtime-and-liveview-integration.md index 6db42a73..69466d55 100644 --- a/specs/planning/phase-04-runtime-and-liveview-integration.md +++ b/specs/planning/phase-04-runtime-and-liveview-integration.md @@ -15,10 +15,10 @@ Back to index: [README](./README.md) - Each LiveView session has isolated state - Events flow through LiveView `handle_event/3` and `handle_info/2` -[ ] 4 Phase 4 - Runtime and LiveView Integration +[X] 4 Phase 4 - Runtime and LiveView Integration Implement the LiveView integration layer that manages screen lifecycle, session state, and event handling. - Status note: mount-time compilation, socket assignment, and event routing are present, but full reactivity still depends on completing the real Ash-backed binding, action, and collection paths reopened in Phase 3. + Status note: mount-time compilation, event routing, and LiveView reactivity now run against real Ash-backed binding paths, including PubSub-driven resource change subscriptions. [X] 4.1 Section - LiveView Mount Integration Implement screen mounting through LiveView `mount/3` callback. @@ -55,26 +55,26 @@ Back to index: [README](./README.md) [X] 4.1.4.3 Subtask - Store binding values in socket assigns [X] 4.1.4.4 Subtask - Handle binding evaluation errors - [ ] 4.2 Section - LiveView Update Integration + [X] 4.2 Section - LiveView Update Integration Implement reactive updates through LiveView `handle_info/2` callback. - [ ] 4.2.1 Task - Subscribe to data changes + [X] 4.2.1 Task - Subscribe to data changes Subscribe to Ash resource change notifications. - [ ] 4.2.1.1 Subtask - Subscribe to real `Ash.Notifier` resource changes + [X] 4.2.1.1 Subtask - Subscribe to real `Ash.Notifier` resource changes [X] 4.2.1.2 Subtask - Filter notifications to bound resources [X] 4.2.1.3 Subtask - Handle subscription messages in `handle_info/2` [X] 4.2.1.4 Subtask - Unsubscribe on unmount - [ ] 4.2.2 Task - Re-render on data changes + [X] 4.2.2 Task - Re-render on data changes Update LiveView when bound data changes. - [ ] 4.2.2.1 Subtask - Re-evaluate affected bindings on notification - [ ] 4.2.2.2 Subtask - Update socket assigns with new values - [ ] 4.2.2.3 Subtask - Trigger LiveView re-render - [ ] 4.2.2.4 Subtask - Batch multiple updates for performance + [X] 4.2.2.1 Subtask - Re-evaluate affected bindings on notification + [X] 4.2.2.2 Subtask - Update socket assigns with new values + [X] 4.2.2.3 Subtask - Trigger LiveView re-render + [X] 4.2.2.4 Subtask - Batch multiple updates for performance - [ ] 4.3 Section - Event Handling Integration + [X] 4.3 Section - Event Handling Integration Implement UI event handling through LiveView `handle_event/3` callback. [X] 4.3.1 Task - Implement event routing @@ -85,21 +85,21 @@ Back to index: [README](./README.md) [X] 4.3.1.3 Subtask - Route to appropriate handler module [X] 4.3.1.4 Subtask - Handle unknown events gracefully - [ ] 4.3.2 Task - Implement value change events + [X] 4.3.2 Task - Implement value change events Handle input value changes from form elements. [X] 4.3.2.1 Subtask - Capture `phx-blur` or `phx-change` events [X] 4.3.2.2 Subtask - Update socket assigns with new value - [ ] 4.3.2.3 Subtask - Write value to Ash resource for `:value` bindings - [ ] 4.3.2.4 Subtask - Handle validation errors + [X] 4.3.2.3 Subtask - Write value to Ash resource for `:value` bindings + [X] 4.3.2.4 Subtask - Handle validation errors - [ ] 4.3.3 Task - Implement action events + [X] 4.3.3 Task - Implement action events Handle button clicks and other action triggers. [X] 4.3.3.1 Subtask - Capture `phx-click` events from buttons [X] 4.3.3.2 Subtask - Extract action binding from event target - [ ] 4.3.3.3 Subtask - Execute Ash action with parameters - [ ] 4.3.3.4 Subtask - Return action result to UI + [X] 4.3.3.3 Subtask - Execute Ash action with parameters + [X] 4.3.3.4 Subtask - Return action result to UI [X] 4.4 Section - Screen Lifecycle Management Implement screen lifecycle hooks and state management. @@ -139,7 +139,7 @@ Back to index: [README](./README.md) [X] 4.5.2.3 Subtask - Log binding errors with context [X] 4.5.2.4 Subtask - Retry binding evaluation on recovery - [ ] 4.6 Section - Phase 4 Integration Tests + [X] 4.6 Section - Phase 4 Integration Tests Validate LiveView integration and lifecycle management end-to-end. [X] 4.6.1 Task - Mount lifecycle integration scenarios diff --git a/specs/planning/phase-05-authorization-and-policy-enforcement.md b/specs/planning/phase-05-authorization-and-policy-enforcement.md index e210b155..7486fd97 100644 --- a/specs/planning/phase-05-authorization-and-policy-enforcement.md +++ b/specs/planning/phase-05-authorization-and-policy-enforcement.md @@ -14,34 +14,34 @@ Back to index: [README](./README.md) - Unauthorized access returns user-friendly errors - Policy failures emit telemetry events -[ ] 5 Phase 5 - Authorization and Policy Enforcement +[X] 5 Phase 5 - Authorization and Policy Enforcement Implement Ash policy integration for UI resource access control and action authorization. - Status note: runtime authorization helpers, policy helper modules, and tests exist. This phase remains open because the resource-level `Ash.Policy.Authorizer` path described here is not fully wired into the persisted Screen, Element, and Binding resources yet. + Status note: persisted Screen, Element, and Binding resources now use `Ash.Policy.Authorizer`, reusable policy checks, and runtime authorization helpers with integration coverage. - [ ] 5.1 Section - Policy Definitions + [X] 5.1 Section - Policy Definitions Define Ash policies for UI resources. - [ ] 5.1.1 Task - Define UI.Screen policies + [X] 5.1.1 Task - Define UI.Screen policies Add policies to screen resource for access control. - [ ] 5.1.1.1 Subtask - Add `policies` block to `AshUI.Screen` resource + [X] 5.1.1.1 Subtask - Add `policies` block to `AshUI.Screen` resource [X] 5.1.1.2 Subtask - Define `:read` policy for screen viewing [X] 5.1.1.3 Subtask - Define `:mount` policy for screen mounting [X] 5.1.1.4 Subtask - Define `:create`, `:update`, `:destroy` policies - [ ] 5.1.2 Task - Define UI.Element policies + [X] 5.1.2 Task - Define UI.Element policies Add policies to element resource for access control. - [ ] 5.1.2.1 Subtask - Add `policies` block to `AshUI.Element` resource + [X] 5.1.2.1 Subtask - Add `policies` block to `AshUI.Element` resource [X] 5.1.2.2 Subtask - Define element visibility policies [X] 5.1.2.3 Subtask - Define element modification policies [X] 5.1.2.4 Subtask - Inherit screen policies where appropriate - [ ] 5.1.3 Task - Define UI.Binding policies + [X] 5.1.3 Task - Define UI.Binding policies Add policies to binding resource for access control. - [ ] 5.1.3.1 Subtask - Add `policies` block to `AshUI.Binding` resource + [X] 5.1.3.1 Subtask - Add `policies` block to `AshUI.Binding` resource [X] 5.1.3.2 Subtask - Define binding evaluation policies [X] 5.1.3.3 Subtask - Define binding modification policies [X] 5.1.3.4 Subtask - Check data source access in binding policies diff --git a/specs/resources/ui_binding.md b/specs/resources/ui_binding.md index 3861ba18..633ae939 100644 --- a/specs/resources/ui_binding.md +++ b/specs/resources/ui_binding.md @@ -43,5 +43,5 @@ Defines persisted runtime bindings for value reads, list reads, and action execu ## Current Gaps -- several runtime paths still use placeholder data loaders or mock action/update results +- runtime binding paths resolve reads, writes, list loading, and actions through real Ash-backed helpers - structured source maps are the implemented baseline; older string path examples are obsolete diff --git a/test/ash_ui/liveview/phase_4_integration_test.exs b/test/ash_ui/liveview/phase_4_integration_test.exs index 9a7b263b..33a88837 100644 --- a/test/ash_ui/liveview/phase_4_integration_test.exs +++ b/test/ash_ui/liveview/phase_4_integration_test.exs @@ -21,6 +21,14 @@ defmodule AshUI.LiveView.Phase4IntegrationTest do defp build_user(id \\ "user-1"), do: %{id: id, name: "Test User"} defp build_screen(id \\ "screen-1"), do: %{id: id, name: "Test Screen"} + defp flush_mailbox do + receive do + _message -> flush_mailbox() + after + 0 -> :ok + end + end + describe "Section 4.6.1 - Mount lifecycle integration scenarios" do test "screen mounts with valid user" do socket = @@ -158,6 +166,7 @@ defmodule AshUI.LiveView.Phase4IntegrationTest do describe "Section 4.6.3 - Reactivity integration scenarios" do test "UI updates when bound data changes" do fixtures = RuntimeFixtures.seed!() + flush_mailbox() socket = build_socket( @@ -174,13 +183,11 @@ defmodule AshUI.LiveView.Phase4IntegrationTest do } ) + assert {:ok, _subscription} = UpdateIntegration.subscribe(socket, User) + {:ok, _updated_user} = Ash.update(fixtures.user, %{name: "Reactive Update"}, domain: RuntimeDomain) - notification = %{ - type: :updated, - resource: User, - timestamp: DateTime.utc_now() - } + assert_receive %Ash.Notifier.Notification{} = notification assert {:noreply, socket} = UpdateIntegration.handle_resource_change(notification, socket) assert socket.assigns[:ash_ui_bindings][:binding1].value == "Reactive Update" @@ -224,16 +231,25 @@ defmodule AshUI.LiveView.Phase4IntegrationTest do end test "subscriptions clean up on unmount" do + fixtures = RuntimeFixtures.seed!() + flush_mailbox() + socket = build_socket() |> Lifecycle.init_session(:dashboard) |> elem(1) # Subscribe to some resources - {:ok, _sub} = UpdateIntegration.subscribe(socket, User.Profile) + {:ok, subscription} = UpdateIntegration.subscribe(socket, User) # Cleanup should remove subscriptions assert :ok = UpdateIntegration.cleanup_subscriptions(socket) + + {:ok, _updated_user} = Ash.update(fixtures.user, %{name: "After cleanup"}, domain: RuntimeDomain) + + refute_receive %Ash.Notifier.Notification{resource: User}, 100 + + assert subscription.resource == User end end @@ -286,6 +302,7 @@ defmodule AshUI.LiveView.Phase4IntegrationTest do test "event flow from UI to Ash and back" do fixtures = RuntimeFixtures.seed!() + flush_mailbox() socket = build_socket( @@ -302,12 +319,14 @@ defmodule AshUI.LiveView.Phase4IntegrationTest do } ) + assert {:ok, _subscription} = UpdateIntegration.subscribe(socket, User) + {:noreply, socket} = EventHandler.handle_value_change(%{"target" => "input-1", "value" => "changed"}, socket) assert socket.assigns[:ash_ui_bindings][:binding1].value == "changed" {:ok, _updated_user} = Ash.update(fixtures.user, %{name: "server change"}, domain: RuntimeDomain) - notification = %{type: :updated, resource: User, timestamp: DateTime.utc_now()} + assert_receive %Ash.Notifier.Notification{} = notification {:noreply, socket} = UpdateIntegration.handle_resource_change(notification, socket) assert socket.assigns[:ash_ui_bindings][:binding1].value == "server change" diff --git a/test/ash_ui/liveview/update_integration_test.exs b/test/ash_ui/liveview/update_integration_test.exs index f6295e98..66f4b416 100644 --- a/test/ash_ui/liveview/update_integration_test.exs +++ b/test/ash_ui/liveview/update_integration_test.exs @@ -1,7 +1,10 @@ defmodule AshUI.LiveView.UpdateIntegrationTest do - use ExUnit.Case, async: true + use ExUnit.Case, async: false alias AshUI.LiveView.UpdateIntegration + alias AshUI.Test.RuntimeDomain + alias AshUI.Test.RuntimeFixtures + alias AshUI.Test.User, as: RuntimeUser # Mock socket for testing defp build_socket(assigns \\ %{}) do @@ -20,6 +23,14 @@ defmodule AshUI.LiveView.UpdateIntegrationTest do %{id: id, name: "Test Screen"} end + defp flush_mailbox do + receive do + _message -> flush_mailbox() + after + 0 -> :ok + end + end + describe "subscribe/3" do test "creates subscription to resource" do socket = build_socket() @@ -53,6 +64,19 @@ defmodule AshUI.LiveView.UpdateIntegrationTest do assert {:ok, _subscription} = UpdateIntegration.subscribe(socket, User.Profile) # In actual implementation, socket would be updated with subscription end + + test "subscribes to real Ash resource notifications" do + fixtures = RuntimeFixtures.seed!() + flush_mailbox() + socket = RuntimeFixtures.socket() + + assert {:ok, _subscription} = UpdateIntegration.subscribe(socket, RuntimeUser) + + assert {:ok, _updated_user} = + Ash.update(fixtures.user, %{name: "Subscribed"}, domain: RuntimeDomain) + + assert_receive %Ash.Notifier.Notification{resource: RuntimeUser, action: %{type: :update}} + end end describe "unsubscribe/2" do @@ -62,6 +86,20 @@ defmodule AshUI.LiveView.UpdateIntegrationTest do assert {:ok, subscription} = UpdateIntegration.subscribe(socket, User.Profile) assert :ok = UpdateIntegration.unsubscribe(socket, subscription) end + + test "stops delivering real Ash resource notifications" do + fixtures = RuntimeFixtures.seed!() + flush_mailbox() + socket = RuntimeFixtures.socket() + + assert {:ok, subscription} = UpdateIntegration.subscribe(socket, RuntimeUser) + assert :ok = UpdateIntegration.unsubscribe(socket, subscription) + + assert {:ok, _updated_user} = + Ash.update(fixtures.user, %{name: "Unsubscribed"}, domain: RuntimeDomain) + + refute_receive %Ash.Notifier.Notification{resource: RuntimeUser}, 100 + end end describe "handle_resource_change/2" do @@ -170,6 +208,41 @@ defmodule AshUI.LiveView.UpdateIntegrationTest do assert {:noreply, _updated_socket} = UpdateIntegration.handle_notification({:unknown, :data}, socket) end + + test "handles Ash.Notifier notifications directly" do + fixtures = RuntimeFixtures.seed!() + flush_mailbox() + + socket = + build_socket( + ash_ui_screen: build_screen(), + ash_ui_user: fixtures.actor, + ash_ui_params: %{}, + ash_ui_bindings: %{ + "binding1" => %{ + id: "binding1", + target: "input-1", + source: %{"resource" => "User", "field" => "name", "id" => fixtures.user.id}, + binding_type: :value, + value: fixtures.user.name + } + }, + ash_ui_domains: [RuntimeDomain] + ) + + assert {:ok, _subscription} = UpdateIntegration.subscribe(socket, RuntimeUser) + + assert {:ok, _updated_user} = + Ash.update(fixtures.user, %{name: "Notifier Update"}, domain: RuntimeDomain) + + assert_receive %Ash.Notifier.Notification{} = notification + + assert {:noreply, updated_socket} = + UpdateIntegration.handle_notification(notification, socket) + + assert updated_socket.assigns[:ash_ui_bindings]["binding1"].value == "Notifier Update" + assert get_in(updated_socket.assigns, [:ash_ui, :bindings, "input-1", "value"]) == "Notifier Update" + end end describe "batch_updates/2" do diff --git a/test/support/runtime_test_resources.ex b/test/support/runtime_test_resources.ex index 74622ef7..0d233088 100644 --- a/test/support/runtime_test_resources.ex +++ b/test/support/runtime_test_resources.ex @@ -14,14 +14,26 @@ end defmodule AshUI.Test.Profile do @moduledoc false + @resource_topic_prefix "ash_ui:resource:AshUI:Test:Profile" + use Ash.Resource, domain: AshUI.Test.RuntimeDomain, + notifiers: [Ash.Notifier.PubSub], data_layer: Ash.DataLayer.Ets ets do private? true end + pub_sub do + module AshUI.Notifications + prefix @resource_topic_prefix + + publish :create, "changes" + publish :update, "changes" + publish :destroy, "changes" + end + attributes do uuid_primary_key :id attribute :name, :string, allow_nil?: false, public?: true @@ -45,14 +57,26 @@ end defmodule AshUI.Test.User do @moduledoc false + @resource_topic_prefix "ash_ui:resource:AshUI:Test:User" + use Ash.Resource, domain: AshUI.Test.RuntimeDomain, + notifiers: [Ash.Notifier.PubSub], data_layer: Ash.DataLayer.Ets ets do private? true end + pub_sub do + module AshUI.Notifications + prefix @resource_topic_prefix + + publish :create, "changes" + publish :update, "changes" + publish :destroy, "changes" + end + attributes do uuid_primary_key :id attribute :name, :string, allow_nil?: false, public?: true @@ -87,14 +111,26 @@ end defmodule AshUI.Test.Post do @moduledoc false + @resource_topic_prefix "ash_ui:resource:AshUI:Test:Post" + use Ash.Resource, domain: AshUI.Test.RuntimeDomain, + notifiers: [Ash.Notifier.PubSub], data_layer: Ash.DataLayer.Ets ets do private? true end + pub_sub do + module AshUI.Notifications + prefix @resource_topic_prefix + + publish :create, "changes" + publish :update, "changes" + publish :destroy, "changes" + end + attributes do uuid_primary_key :id attribute :title, :string, allow_nil?: false, public?: true @@ -124,14 +160,26 @@ end defmodule AshUI.Test.Comment do @moduledoc false + @resource_topic_prefix "ash_ui:resource:AshUI:Test:Comment" + use Ash.Resource, domain: AshUI.Test.RuntimeDomain, + notifiers: [Ash.Notifier.PubSub], data_layer: Ash.DataLayer.Ets ets do private? true end + pub_sub do + module AshUI.Notifications + prefix @resource_topic_prefix + + publish :create, "changes" + publish :update, "changes" + publish :destroy, "changes" + end + attributes do uuid_primary_key :id attribute :content, :string, allow_nil?: false, public?: true