diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 00000000..303e7b17 --- /dev/null +++ b/.tool-versions @@ -0,0 +1,3 @@ +erlang 28.3.1 +elixir 1.19.5-otp-28 +nodejs 22.14.0 diff --git a/lib/ash_ui/runtime/action_binding.ex b/lib/ash_ui/runtime/action_binding.ex new file mode 100644 index 00000000..8c38d99f --- /dev/null +++ b/lib/ash_ui/runtime/action_binding.ex @@ -0,0 +1,272 @@ +defmodule AshUI.Runtime.ActionBinding do + @moduledoc """ + Event-driven binding for `:action` type bindings. + + Handles execution of Ash actions in response to UI events + with proper authorization and error handling. + """ + + alias AshUI.Resources.Binding + + @type context :: %{ + user_id: String.t() | nil, + params: map(), + assigns: map() + } + + @type action_result :: %{ + status: :ok | :error, + data: map() | nil, + errors: [map()] | nil + } + + @doc """ + Executes an Ash action in response to a UI event. + + ## Parameters + * binding - The action binding to execute + * event_data - Data from the UI event (form values, click data, etc.) + * context - Execution context with user info + * opts - Options + + ## Returns + * `{:ok, action_result}` - Action executed successfully + * `{:error, reason}` - Action execution failed + + ## Examples + + iex> binding = %{ + ...> source: %{"resource" => "User", "action" => "create"}, + ...> binding_type: :action + ...> } + iex> event_data = %{"name" => "John", "email" => "john@example.com"} + iex> AshUI.Runtime.ActionBinding.execute_action(binding, event_data, context) + {:ok, %{status: :ok, data: %{...}}} + """ + @spec execute_action(Binding.t() | map(), map(), context(), keyword()) :: + {:ok, action_result()} | {:error, term()} + def execute_action(binding, event_data, context, opts \\ []) do + source = binding.source || %{} + resource = Map.get(source, "resource") + action_name = Map.get(source, "action") + + with {:ok, _} <- check_authorization(binding, context), + {:ok, params} <- prepare_params(binding, event_data, context), + {:ok, result} <- call_ash_action(resource, action_name, params, context, opts) do + {:ok, + %{ + status: :ok, + data: result, + errors: nil + }} + else + {:error, reason} -> + {:error, + %{ + status: :error, + data: nil, + errors: format_action_error(reason) + }} + end + end + + @doc """ + Generates a LiveView event handler from an action binding. + + ## Parameters + * binding - The action binding + * element_id - The UI element ID + + ## Returns + * Event handler function for LiveView + + ## Examples + + iex> binding = %{source: %{"resource" => "User", "action" => "delete"}} + iex> handler = AshUI.Runtime.ActionBinding.event_handler(binding, "btn-1") + iex> handler.(socket, %{"value" => "123"}, %{"target" => "btn-1"}) + {:noreply, updated_socket} + """ + @spec event_handler(Binding.t() | map(), String.t()) :: function() + def event_handler(binding, element_id) do + fn socket, event_data, _event_opts -> + context = build_context(socket) + + case execute_action(binding, event_data, context) do + {:ok, result} -> + handle_action_success(socket, binding, result) + + {:error, _reason} -> + handle_action_error(socket, binding) + end + end + end + + @doc """ + Wires action bindings to LiveView handle_event/3. + + ## Parameters + * bindings - List of action bindings + * socket - LiveView socket + + ## Returns + * Map of event_name to handler function + """ + @spec wire_handlers([Binding.t() | map()], map()) :: %{String.t() => function()} + def wire_handlers(bindings, socket) do + action_bindings = + Enum.filter(bindings, 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") + element_id = get_binding_element_id(binding) + + handler_name = "ash_ui_action_#{target || element_id}" + + Map.put(acc, handler_name, event_handler(binding, element_id)) + end) + end + + # Check authorization before executing action + defp check_authorization(binding, context) do + resource = get_in(binding, [:source, "resource"]) + action = get_in(binding, [:source, "action"]) + user_id = Map.get(context, :user_id) + + # In production, this would call Ash.can?/3 + # For now, allow if user_id is present + if user_id do + :ok + else + {:error, :unauthorized} + end + end + + # Prepare parameters from event data and binding config + defp prepare_params(binding, event_data, context) do + param_mapping = get_in(binding, [:transform, "params"]) || %{} + + params = + Enum.reduce(param_mapping, %{}, fn {key, source}, acc -> + value = get_param_value(source, event_data, context) + Map.put(acc, key, value) + end) + + # Merge event data directly if no mapping + merged_params = + if param_mapping == %{} do + Map.merge(params, event_data) + else + params + end + + {:ok, merged_params} + end + + defp get_param_value({"event", key}, event_data, _context) do + Map.get(event_data, key) + end + + defp get_param_value({"context", key}, _event_data, context) do + Map.get(context, key) + end + + defp get_param_value({"static", value}, _event_data, _context) do + value + end + + defp get_param_value(_source, _event_data, _context), do: nil + + # Call Ash action + defp call_ash_action(resource, action_name, params, _context, _opts) do + # In production, this would call the actual Ash action + # Ash.run(Ash.Domain, resource, action_name, params) + mock_action_result(resource, action_name, params) + end + + defp mock_action_result(resource, action_name, params) do + # Mock action result + { + :ok, + %{ + "resource" => resource, + "action" => action_name, + "params" => params, + "result" => %{"id" => UUID.uuid4()} + } + } + end + + # Handle successful action + defp handle_action_success(socket, binding, result) do + target = binding.target || Map.get(binding, "target") + + # Store result in assigns + updated_socket = + put_in(socket.assigns, [:ash_ui, :actions, target, "result"], result) + + # Clear any previous errors + updated_socket = + put_in(updated_socket.assigns, [:ash_ui, :actions, target, "error"], nil) + + # Show success message if configured + updated_socket = + if success_message = get_in(binding, [:metadata, "success_message"]) do + put_flash(updated_socket, :info, success_message) + else + updated_socket + end + + {:noreply, updated_socket} + end + + # Handle action error + defp handle_action_error(socket, binding) do + target = binding.target || Map.get(binding, "target") + + # Store error in assigns + updated_socket = + put_in(socket.assigns, [:ash_ui, :actions, target, "error"], "Action failed") + + # Show error message + error_message = get_in(binding, [:metadata, "error_message"]) || "Action failed" + updated_socket = put_flash(socket, :error, error_message) + + {:noreply, updated_socket} + end + + # Build context from socket + defp build_context(socket) do + user_id = get_in(socket, :assigns, :current_user_id) + params = get_in(socket, :assigns, :params) || %{} + + %{ + user_id: user_id, + params: params, + assigns: socket.assigns + } + end + + # Format action error for display + defp format_action_error(reason) when is_binary(reason), do: [%{"message" => reason}] + 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 + + defp put_flash(socket, kind, message) do + # In production, this would use Phoenix.LiveView.put_flash/3 + # For now, store in assigns + flash = get_in(socket.assigns, :flash) || %{} + updated_flash = Map.update(flash, kind, [message | _], fn messages -> [message | messages] end) + put_in(socket, :assigns, :flash, updated_flash) + end +end diff --git a/lib/ash_ui/runtime/bidirectional_binding.ex b/lib/ash_ui/runtime/bidirectional_binding.ex new file mode 100644 index 00000000..28a5c63b --- /dev/null +++ b/lib/ash_ui/runtime/bidirectional_binding.ex @@ -0,0 +1,262 @@ +defmodule AshUI.Runtime.BidirectionalBinding do + @moduledoc """ + Bidirectional value binding for two-way data flow. + + Handles reading from Ash resources to UI elements and writing + user input back to Ash resources with proper change tracking. + """ + + alias AshUI.Runtime.BindingEvaluator + alias AshUI.Resources.Binding + + @type socket :: map() + @type context :: %{ + user_id: String.t() | nil, + params: map(), + assigns: map() + } + + @doc """ + Reads binding value from Ash resource and updates socket assigns. + + This is the "read" direction of bidirectional binding. + + ## Parameters + * binding - The binding to read + * socket - LiveView socket + * context - Evaluation context + + ## Returns + * `{:ok, socket}` - Updated socket with binding value + * `{:error, reason}` - Read failed + """ + @spec read_binding(Binding.t() | map(), socket(), context()) :: {:ok, socket()} | {:error, term()} + def read_binding(binding, socket, context) do + with {:ok, value} <- BindingEvaluator.evaluate(binding, context) do + updated_socket = put_binding_value(socket, binding, value) + {:ok, updated_socket} + end + end + + @doc """ + Writes user input from UI element back to Ash resource. + + This is the "write" direction of bidirectional binding. + + ## Parameters + * binding - The binding to write + * new_value - The value from user input + * socket - LiveView socket + * context - Evaluation context + + ## Returns + * `{:ok, socket, result}` - Write succeeded, updated socket and Ash result + * `{:error, reason, socket}` - Write failed, socket with error state + """ + @spec write_binding(Binding.t() | map(), term(), socket(), context()) :: + {:ok, socket(), map()} | {:error, term(), socket()} + def write_binding(binding, new_value, socket, context) do + with :ok <- validate_input(binding, new_value), + {:ok, sanitized} <- sanitize_input(binding, new_value), + {:ok, result} <- update_resource(binding, sanitized, context) do + updated_socket = put_binding_value(socket, binding, sanitized) + {:ok, updated_socket, result} + else + {:error, reason} -> + error_socket = put_binding_error(socket, binding, reason) + {:error, reason, error_socket} + end + end + + @doc """ + Subscribes to Ash resource changes for automatic re-evaluation. + + ## Parameters + * binding - The binding to subscribe + * socket - LiveView socket + * context - Evaluation context + + ## Returns + * `{:ok, socket}` - Subscribed, socket with tracking info + """ + @spec subscribe_binding(Binding.t() | map(), socket(), context()) :: {:ok, socket()} + def subscribe_binding(binding, socket, context) do + # Track subscription for this binding + subscription_id = subscription_id(binding) + + # In production, this would subscribe to Ash.Notifier + # For now, track in socket assigns + subscriptions = get_in(socket.assigns, [:ash_ui, :subscriptions]) || %{} + + updated_subscriptions = + Map.put(subscriptions, subscription_id, %{ + binding_id: get_binding_id(binding), + source: binding.source, + target: binding.target, + subscribed_at: System.system_time(:millisecond) + }) + + updated_socket = + put_in(socket.assigns, [:ash_ui, :subscriptions], updated_subscriptions) + + {:ok, updated_socket} + end + + @doc """ + Re-evaluates binding on resource change notification. + + ## Parameters + * binding - The binding to re-evaluate + * change_data - Details of what changed + * socket - LiveView socket + * context - Evaluation context + + ## Returns + * `{:ok, socket, changed?}` - Re-evaluated, socket, whether value changed + """ + @spec reevaluate_binding(Binding.t() | map(), map(), socket(), context()) :: + {:ok, socket(), boolean()} + def reevaluate_binding(binding, _change_data, socket, context) do + old_value = get_binding_value(socket, binding) + + case BindingEvaluator.evaluate(binding, context) do + {:ok, new_value} -> + changed = old_value != new_value + updated_socket = put_binding_value(socket, binding, new_value) + {:ok, updated_socket, changed} + + {:error, _reason} -> + # Keep old value on error, but log + {:ok, socket, false} + end + end + + # Validate user input before writing + defp validate_input(binding, value) do + # Check if binding has validation rules + validation = get_in(binding, [:transform, "validate"]) + + if validation do + apply_validation(binding, value, validation) + else + :ok + end + end + + defp apply_validation(_binding, _value, nil), do: :ok + + 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} + {:error, reason} -> {:halt, {:error, reason}} + end + end) + end + + defp apply_validation(_binding, _value, _rule), do: :ok + + defp validate_with_rule(%{"type" => "required"}, value) do + if value in [nil, ""] do + {:error, :required} + else + :ok + end + end + + defp validate_with_rule(%{"type" => "min_length", "value" => min}, value) do + if is_binary(value) and String.length(value) >= min do + :ok + else + {:error, {:min_length, min}} + end + end + + defp validate_with_rule(%{"type" => "max_length", "value" => max}, value) do + if is_binary(value) and String.length(value) <= max do + :ok + else + {:error, {:max_length, max}} + end + end + + defp validate_with_rule(_rule, _value), do: :ok + + # Sanitize user input + defp sanitize_input(binding, value) do + # Apply sanitization rules from binding transform + sanitization = get_in(binding, [:transform, "sanitize"]) + + case sanitization do + nil -> {:ok, value} + rules when is_list(rules) -> apply_sanitization(value, rules) + _ -> {:ok, value} + end + 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) + end + + defp sanitize_with_rule(%{"type" => "trim"}, value) when is_binary(value) do + {:ok, String.trim(value)} + end + + defp sanitize_with_rule(%{"type" => "strip_tags"}, value) when is_binary(value) do + # Placeholder: In production, use HTML sanitization library + {:ok, value} + end + + defp sanitize_with_rule(_rule, value), do: {:ok, value} + + # Update Ash resource with new value + defp update_resource(binding, value, context) do + source = binding.source || %{} + resource = Map.get(source, "resource") + field = Map.get(source, "field") + id = get_resource_id(source, context) + + # In production, this would call Ash.Domain.update/3 + # For now, return a mock result + mock_update_result(resource, id, field, value) + end + + defp get_resource_id(source, context) do + Map.get(source, "id") || Map.get(context, :resource_id) + end + + defp mock_update_result(_resource, _id, _field, value) do + {:ok, %{"status" => "updated", "value" => value}} + end + + # Helper functions for socket management + defp put_binding_value(socket, binding, value) do + target = binding.target || Map.get(binding, "target") + put_in(socket.assigns, [:ash_ui, :bindings, target], %{ + "value" => value, + "updated_at" => System.system_time(:millisecond) + }) + end + + defp get_binding_value(socket, binding) do + target = binding.target || Map.get(binding, "target") + get_in(socket.assigns, [:ash_ui, :bindings, target, "value"]) + end + + defp put_binding_error(socket, binding, error) do + target = binding.target || Map.get(binding, "target") + put_in(socket.assigns, [:ash_ui, :bindings, target, "error"], error) + end + + defp get_binding_id(%Binding{id: id}), do: id + defp get_binding_id(binding), do: Map.get(binding, :id) || Map.get(binding, "id") + + defp subscription_id(binding) do + "#{get_binding_id(binding)}_#{System.system_time(:millisecond)}" + end +end diff --git a/lib/ash_ui/runtime/binding_evaluator.ex b/lib/ash_ui/runtime/binding_evaluator.ex new file mode 100644 index 00000000..d6ac2482 --- /dev/null +++ b/lib/ash_ui/runtime/binding_evaluator.ex @@ -0,0 +1,257 @@ +defmodule AshUI.Runtime.BindingEvaluator do + @moduledoc """ + Runtime evaluator for resolving bindings against Ash resource data. + + This module handles the evaluation of bindings at runtime, connecting + UI elements to actual Ash resource data with proper authorization. + """ + + alias AshUI.Resources.Binding + + @type context :: %{ + user_id: String.t() | nil, + params: map(), + assigns: map() + } + + @type evaluation_result :: {:ok, term()} | {:error, term()} + + @doc """ + Evaluates a binding against Ash resource data. + + ## Parameters + * binding - The binding to evaluate + * context - Map with user_id, params, and assigns + * opts - Options including cache settings + + ## Returns + * `{:ok, value}` - Successfully evaluated + * `{:error, reason}` - Evaluation failed + + ## Examples + + iex> context = %{user_id: "user-1", params: %{}, assigns: %{}} + iex> binding = %AshUI.Resources.Binding{ + ...> source: %{"resource" => "User", "field" => "name"} + ...> } + iex> AshUI.Runtime.BindingEvaluator.evaluate(binding, context) + {:ok, "John Doe"} + """ + @spec evaluate(Binding.t() | map(), context(), keyword()) :: evaluation_result() + def evaluate(binding, context, opts \\ []) + + def evaluate(%Binding{} = binding, context, opts) do + source_map = binding.source || %{} + + with {:ok, value} <- resolve_source(source_map, context, opts), + {:ok, transformed} <- apply_transformations(value, binding.transform, context) do + {:ok, transformed} + end + end + + def evaluate(binding, context, opts) when is_map(binding) do + source = Map.get(binding, :source) || Map.get(binding, "source", %{}) + transform = Map.get(binding, :transform) || Map.get(binding, "transform", %{}) + + with {:ok, value} <- resolve_source(source, context, opts), + {:ok, transformed} <- apply_transformations(value, transform, context) do + {:ok, transformed} + end + end + + # Resolve source path to actual value + 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) + end + end + + defp resolve_source(source, _context, _opts) do + {:error, {:invalid_source, source}} + end + + # Resolve field or relationship from resource + defp resolve_field_or_relationship(%{"resource" => resource} = source, context, opts) do + field = Map.get(source, "field") + relationship = Map.get(source, "relationship") + id = Map.get(source, "id") + + cond do + field -> + resolve_field(resource, field, id, context, opts) + + relationship -> + resolve_relationship(resource, relationship, context, opts) + + true -> + {:error, {:missing_field_or_relationship, source}} + end + end + + # Resolve a single field from a resource + defp resolve_field(resource_name, field, id, context, _opts) do + # Build Ash query to read the resource + # In production, this would use the actual Ash domain and resources + # For now, return a placeholder + case load_resource(resource_name, id, context) do + {:ok, resource} -> + value = get_field(resource, field) + {:ok, value} + + {:error, reason} -> + {:error, reason} + end + end + + # Resolve a relationship (e.g., user.profile.name) + 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 + end + + # Navigate through nested relationships + defp navigate_relationship(nil, _parts, _context), do: {:ok, nil} + + defp navigate_relationship(resource, [part | rest], context) do + value = get_field(resource, part) + + if rest == [] do + {:ok, value} + else + navigate_relationship(value, rest, context) + end + end + + # Resolve an action source + defp resolve_action(source, action_name, context, _opts) do + resource = Map.get(source, "resource") + + # Actions don't have values to read + # Return action metadata instead + {:ok, + %{ + "type" => "action", + "resource" => resource, + "action" => action_name + }} + end + + # Load a resource by name and ID + defp load_resource(_resource_name, _id, _context) do + # Placeholder: In production, this would call Ash.Domain.get/3 + # For now, return mock data + {:ok, + %{ + "id" => "mock-id", + "name" => "Mock Resource", + "type" => "mock" + }} + end + + # Get a field from a resource (map or struct) + defp get_field(resource, field) when is_map(resource) do + key = String.to_existing_atom(field) + + case Map.get(resource, key) do + nil -> Map.get(resource, field) + value -> value + end + rescue + ArgumentError -> + Map.get(resource, field) + end + + defp get_field(_resource, _field), do: nil + + # Apply transformations to the resolved value + defp apply_transformations(value, transform, _context) do + transforms = 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} + end + end) + end + + # Apply a single transformation + defp apply_single_transform(value, %{"function" => "default"} = transform) do + args = Map.get(transform, "args", []) + + if value == nil || value == "" do + default = List.first(args) + {:ok, default} + else + {:ok, value} + end + end + + defp apply_single_transform(value, %{"function" => "format"}) do + # Format transformation - would use specific format rules + {:ok, format_value(value)} + end + + defp apply_single_transform(value, %{"function" => "uppercase"}) do + {:ok, String.upcase(to_string(value))} + end + + defp apply_single_transform(value, %{"function" => "lowercase"}) do + {:ok, String.downcase(to_string(value))} + end + + defp apply_single_transform(value, %{"function" => "trim"}) do + {:ok, String.trim(to_string(value))} + end + + defp apply_single_transform(value, %{"function" => "compute"}) do + # Compute transformation - would apply calculation + {:ok, value} + end + + defp apply_single_transform(value, %{"function" => "validate"}) do + # Validate transformation - would check constraints + {:ok, value} + end + + defp apply_single_transform(_value, transform) do + # Unknown transformation - pass through + {:ok, nil} + end + + # Format a value (placeholder implementation) + defp format_value(value) when is_binary(value), do: value + defp format_value(value) when is_number(value), do: to_string(value) + defp format_value(value), do: inspect(value) + + @doc """ + Batch evaluates multiple bindings. + + ## Parameters + * bindings - List of bindings to evaluate + * context - Evaluation context + * opts - Options + + ## Returns + * Map of binding_id to result + """ + @spec evaluate_batch([Binding.t() | map()], context(), keyword()) :: %{String.t() => evaluation_result()}} + def evaluate_batch(bindings, context, opts \\ []) do + Enum.reduce(bindings, %{}, fn binding, acc -> + id = get_binding_id(binding) + result = evaluate(binding, context, opts) + Map.put(acc, id, result) + end) + end + + defp get_binding_id(%Binding{id: id}), do: id + defp get_binding_id(binding), do: Map.get(binding, :id) || Map.get(binding, "id") +end diff --git a/lib/ash_ui/runtime/list_binding.ex b/lib/ash_ui/runtime/list_binding.ex new file mode 100644 index 00000000..5b13087e --- /dev/null +++ b/lib/ash_ui/runtime/list_binding.ex @@ -0,0 +1,296 @@ +defmodule AshUI.Runtime.ListBinding do + @moduledoc """ + Collection binding for `:list` type bindings. + + Handles loading, binding, and reactive updates for collections + of Ash resources to UI elements like lists and tables. + """ + + alias AshUI.Runtime.BindingEvaluator + + @type context :: %{ + user_id: String.t() | nil, + params: map(), + assigns: map() + } + + @type list_result :: %{ + items: [map()], + total: integer(), + page: integer(), + page_size: integer(), + has_next: boolean(), + has_prev: boolean() + } + + @doc """ + Loads a collection from Ash resource based on list binding. + + ## Parameters + * binding - The list binding to load + * context - Evaluation context + * opts - Options including pagination and filtering + + ## Returns + * `{:ok, list_result}` - Collection loaded successfully + * `{:error, reason}` - Load failed + + ## Examples + + iex> binding = %{ + ...> source: %{"resource" => "Post", "relationship" => "comments"}, + ...> binding_type: :list + ...> } + iex> AshUI.Runtime.ListBinding.load_collection(binding, context) + {:ok, %{items: [...], total: 10, page: 1, ...}} + """ + @spec load_collection(map(), context(), keyword()) :: {:ok, list_result()} | {:error, term()} + def load_collection(binding, context, opts \\ []) do + source = binding.source || %{} + resource = Map.get(source, "resource") + relationship = Map.get(source, "relationship") + + page = Keyword.get(opts, :page, 1) + page_size = Keyword.get(opts, :page_size, 20) + filters = Keyword.get(opts, :filters, %{}) + + with {:ok, collection} <- + load_resource_collection(resource, relationship, page, page_size, filters, context) do + total = get_total_count(collection) + items = extract_items(collection) + + list_result = %{ + items: items, + total: total, + page: page, + page_size: page_size, + has_next: total > page * page_size, + has_prev: page > 1 + } + + {:ok, list_result} + end + end + + @doc """ + Subscribes to collection changes for reactive updates. + + ## Parameters + * binding - The list binding to subscribe + * socket - LiveView socket + * context - Evaluation context + + ## Returns + * `{:ok, socket}` - Subscribed successfully + """ + @spec subscribe_collection(map(), map(), context()) :: {:ok, map()} + def subscribe_collection(binding, socket, context) do + subscription_id = collection_subscription_id(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"), + subscribed_at: System.system_time(:millisecond) + } + + updated_subscriptions = Map.put(subscriptions, subscription_id, subscription) + updated_socket = put_in(socket.assigns, [:ash_ui, :list_subscriptions], updated_subscriptions) + + {:ok, updated_socket} + end + + @doc """ + Handles collection change notification and updates UI. + + ## Parameters + * binding - The list binding + * change_type - :insert, :update, :delete + * change_data - Details of what changed + * socket - LiveView socket + * context - Evaluation context + + ## Returns + * `{:ok, socket, should_update?}` - Updated socket and whether to re-render + """ + @spec handle_collection_change(map(), atom(), map(), map(), context()) :: + {:ok, map(), boolean()} + def handle_collection_change(binding, change_type, change_data, socket, context) do + case change_type do + :insert -> + handle_insert(binding, change_data, socket, context) + + :update -> + handle_update(binding, change_data, socket, context) + + :delete -> + handle_delete(binding, change_data, socket, context) + end + end + + @doc """ + Formats a collection for UI display with transformations applied. + + ## Parameters + * list_result - The loaded collection + * binding - The binding with transformation rules + * context - Evaluation context + + ## Returns + * `{:ok, formatted_items}` - Formatted collection items + """ + @spec format_collection(list_result(), map(), context()) :: {:ok, [map()]} | {:error, term()} + def format_collection(list_result, binding, context) do + transform = binding.transform || %{} + items = list_result.items + + formatted = + Enum.map(items, fn item -> + format_item(item, transform, context) + end) + + {:ok, formatted} + end + + # Private functions + + defp load_resource_collection(resource, relationship, page, page_size, filters, _context) do + # In production, this would use Ash.Query to load the collection + # For now, return mock data + mock_load_collection(resource, relationship, page, page_size, filters) + end + + defp mock_load_collection(resource, relationship, page, page_size, _filters) do + # 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) + + {:ok, + %{ + "items" => items, + "total" => 100, # Mock total + "page" => page + }} + end + + defp get_total_count(collection) do + Map.get(collection, "total", length(Map.get(collection, "items", []))) + end + + defp extract_items(collection) do + Map.get(collection, "items", []) + end + + defp handle_insert(binding, change_data, socket, context) do + # For insert, we may want to prepend to the list or refresh + target = binding.target || Map.get(binding, "target") + + # Store change for UI update + changes = get_in(socket.assigns, [:ash_ui, :list_changes, target]) || [] + updated_changes = [{:insert, change_data} | changes] + updated_socket = put_in(socket.assigns, [:ash_ui, :list_changes, target], updated_changes) + + {:ok, updated_socket, true} + end + + defp handle_update(binding, change_data, socket, _context) do + # For update, find the item and update it + target = 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"]) || [] + + updated_items = + Enum.map(items, fn item -> + if Map.get(item, "id") == item_id do + Map.merge(item, change_data) + else + item + end + end) + + updated_socket = put_in(socket.assigns, [:ash_ui, :lists, target, "items"], updated_items) + + {:ok, updated_socket, true} + end + + defp handle_delete(binding, change_data, socket, _context) do + # For delete, remove the item from the list + target = binding.target || Map.get(binding, "target") + item_id = Map.get(change_data, "id") + + items = get_in(socket.assigns, [:ash_ui, :lists, target, "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) + + {:ok, updated_socket, true} + end + + defp format_item(item, transform, context) do + # Apply transformations to each item + Enum.reduce(transform, item, fn {key, rules}, acc -> + apply_item_transform(acc, key, rules, context) + end) + end + + defp apply_item_transform(item, key, rules, context) when is_map(rules) do + current_value = Map.get(item, key) + + case Map.get(rules, "function") do + "format" -> + format_value = Map.get(rules, "format") + Map.put(item, key, do_format(current_value, format_value)) + + "compute" -> + computed = compute_value(item, key, rules, context) + Map.put(item, key, computed) + + _ -> + item + end + end + + defp apply_item_transform(item, _key, _rules, _context), do: item + + defp do_format(value, format_string) when is_binary(format_string) do + # Simple format string replacement + # In production, use more sophisticated formatting + String.replace(format_string, "{value}", to_string(value)) + end + + defp do_format(value, _format), do: value + + defp compute_value(item, key, rules, _context) do + expression = Map.get(rules, "expression") + # In production, would evaluate expression safely + # For now, return the original value + Map.get(item, key, expression) + end + + defp get_binding_id(binding) do + Map.get(binding, :id) || Map.get(binding, "id") + end + + defp collection_subscription_id(binding) do + resource = get_in(binding, [:source, "resource"]) + relationship = get_in(binding, [: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 new file mode 100644 index 00000000..e3557c15 --- /dev/null +++ b/lib/ash_ui/signal/cloud_events.ex @@ -0,0 +1,238 @@ +defmodule AshUI.Signal.CloudEvents do + @moduledoc """ + CloudEvents-compatible signal format for unified signal transport. + + Wraps Ash UI signals in the CloudEvents standard format for + compatibility with the unified-ui signal transport specification. + """ + + alias AshUI.Signal.Struct + + @type cloud_event :: %{ + required: [String.t()], + "id": String.t(), + "source": String.t(), + "type": String.t(), + "datacontenttype": String.t(), + "data": map() + } + + @doc """ + Converts an Ash UI signal to CloudEvents format. + + ## CloudEvents Spec + https://github.com/cloudevents/spec/blob/v1.0.2/cloudevents.md + + ## Returns + * CloudEvents map with required fields + + ## Examples + + iex> signal = AshUI.Signal.Struct.bidirectional("User.name", "input-1") + iex> AshUI.Signal.CloudEvents.to_cloud_event(signal) + %{ + "id" => "signal-123", + "source" => "ash-ui/User.name", + "type" => "ash_ui.signal.bidirectional", + "datacontenttype" => "application/json", + "data" => %{...} + } + """ + @spec to_cloud_event(Struct.t()) :: cloud_event() + def to_cloud_event(%Struct{} = signal) do + %{ + "id" => signal.id, + "source" => build_source(signal), + "type" => build_type(signal), + "datacontenttype" => "application/json", + "data" => build_data(signal), + "time" => DateTime.utc_now() |> DateTime.to_iso8601(), + "ashui" => %{ + "target" => signal.target, + "transform" => signal.transform + } + } + end + + @doc """ + Converts a CloudEvents event back to an Ash UI signal. + + ## Returns + * `{:ok, AshUI.Signal.Struct.t()}` or `{:error, reason}` + """ + @spec from_cloud_event(cloud_event()) :: {:ok, Struct.t()} | {:error, term()} + def from_cloud_event(cloud_event) when is_map(cloud_event) do + with :ok <- validate_cloud_event(cloud_event), + {:ok, type} <- parse_signal_type(cloud_event["type"]), + {:ok, source} <- parse_signal_source(cloud_event["source"]) do + signal = Struct.new( + id: cloud_event["id"], + source: source, + target: get_in(cloud_event, ["ashui", "target"], ""), + type: type, + transform: get_in(cloud_event, ["ashui", "transform"]), + metadata: extract_metadata(cloud_event) + ) + + {:ok, signal} + end + end + + @doc """ + Wraps a list of signals in a CloudEvents batch envelope. + + ## Returns + * CloudEvents batch envelope + """ + @spec batch envelopes([Struct.t()]) :: map() + def batch(signals) when is_list(signals) do + events = Enum.map(signals, &to_cloud_event/1) + + %{ + "specversion" => "1.0", + "id" => generate_batch_id(), + "source" => "ash-ui", + "type" => "ash_ui.signal.batch", + "datacontenttype" => "application/json", + "data" => %{"events" => events}, + "time" => DateTime.utc_now() |> DateTime.to_iso8601() + } + end + + @type envelopes :: :json | :binary | :text + + @doc """ + Serializes a CloudEvents event to a specific format. + + ## Options + * `:format` - :json, :binary, or :text (default: :json) + + ## Returns + * Serialized event + + ## Examples + + iex> signal = AshUI.Signal.Struct.bidirectional("User.name", "input-1") + iex> AshUI.Signal.CloudEvents.serialize(signal, :json) + "{\\"id\\": \\"signal-123\\", ...}" + """ + @spec serialize(cloud_event() | Struct.t(), keyword()) :: String.t() | binary() + def serialize(%Struct{} = signal, opts \\ []) do + event = to_cloud_event(signal) + serialize(event, opts) + end + + def serialize(cloud_event, opts) when is_map(cloud_event) do + format = Keyword.get(opts, :format, :json) + + case format do + :json -> + Jason.encode!(cloud_event) + + :binary -> + # In production, would use proper binary encoding + Jason.encode!(cloud_event) + + :text -> + # Human-readable text format + format_text(cloud_event) + end + end + + # Private functions + + defp build_source(%Struct{source: source}) do + resource = Map.get(source, "resource", "") + field = Map.get(source, "field", "") + "ash-ui/#{resource}/#{field}" + end + + defp build_type(%Struct{type: type}) do + "ash_ui.signal.#{Atom.to_string(type)}" + end + + defp build_data(%Struct{} = signal) do + %{ + "source" => signal.source, + "target" => signal.target, + "value" => get_current_value(signal) + } + end + + defp get_current_value(%Struct{}) do + # In production, would fetch current value from context + nil + end + + defp validate_cloud_event(cloud_event) do + required = ["id", "source", "type"] + + missing = + Enum.reject(required, fn key -> Map.has_key?(cloud_event, key) end) + + if missing == [] do + :ok + else + {:error, {:missing_required_fields, missing}} + end + end + + defp parse_signal_type("ash_ui.signal." <> type_str) do + type = String.to_existing_atom(type_str) + + if type in [:bidirectional, :collection, :event] do + {:ok, type} + else + {:error, {:unknown_type, type}} + end + rescue + ArgumentError -> {:error, {:unknown_type, type_str}} + end + + defp parse_signal_type(type), do: {:error, {:unknown_type, type}} + + defp parse_signal_source("ash-ui/" <> rest) do + case String.split(rest, "/", parts: 2) do + [resource, field] -> + {:ok, + %{ + "type" => "field", + "resource" => resource, + "field" => field + }} + + _ -> + {:error, {:invalid_source, rest}} + end + end + + defp parse_signal_source(source), do: {:error, {:invalid_source, source}} + + defp extract_metadata(cloud_event) do + # Extract time and other CloudEvents metadata + time = Map.get(cloud_event, "time") + %{"cloud_events" => %{"time" => time}} + end + + defp generate_batch_id do + "batch_#{System.system_time(:millisecond)}_#{:rand.uniform(10000)}" + end + + defp format_text(cloud_event) do + """ + CloudEvent: #{cloud_event["type"]} + ID: #{cloud_event["id"]} + Source: #{cloud_event["source"]} + Time: #{cloud_event["time"]} + """ + end + + @type envelope :: %{ + "specversion": String.t(), + "id": String.t(), + "source": String.t(), + "type": String.t(), + "datacontenttype": String.t(), + "data": map() + } +end diff --git a/lib/ash_ui/signal/struct.ex b/lib/ash_ui/signal/struct.ex new file mode 100644 index 00000000..3a98d507 --- /dev/null +++ b/lib/ash_ui/signal/struct.ex @@ -0,0 +1,227 @@ +defmodule AshUI.Signal.Struct do + @moduledoc """ + Signal structure matching unified-ui signal transport spec. + + Provides the canonical signal format used throughout Ash UI + for communication between UI elements and Ash resources. + """ + + @type t :: %__MODULE__{ + id: String.t(), + source: signal_source(), + target: String.t(), + type: signal_type(), + transform: map() | nil, + metadata: map() + } + + @type signal_type :: :bidirectional | :collection | :event + @type signal_source :: %{ + "type" => String.t(), + "resource" => String.t() | nil, + "field" => String.t() | nil, + "action" => String.t() | nil, + "relationship" => String.t() | nil + } + + defstruct [ + :id, + :source, + :target, + :type, + :transform, + metadata: %{} + ] + + @doc """ + Creates a new signal struct. + + ## Options + * `:id` - Unique signal identifier + * `:source` - Signal source map with type/resource/field + * `:target` - Target element ID + * `:type` - Signal type (:bidirectional, :collection, :event) + * `:transform` - Transformation rules + * `:metadata` - Additional metadata + + ## Examples + + iex> AshUI.Signal.Struct.new( + ...> id: "signal-1", + ...> source: %{"type" => "field", "resource" => "User", "field" => "name"}, + ...> target: "input-name", + ...> type: :bidirectional + ...> ) + """ + @spec new(keyword()) :: t() + def new(opts \\ []) do + id = Keyword.get(opts, :id, generate_id()) + source = Keyword.get(opts, :source, %{}) + target = Keyword.get(opts, :target, "") + type = Keyword.get(opts, :type, :bidirectional) + transform = Keyword.get(opts, :transform) + metadata = Keyword.get(opts, :metadata, %{}) + + %__MODULE__{ + id: id, + source: normalize_source(source), + target: target, + type: type, + transform: transform, + metadata: metadata + } + end + + @doc """ + Creates a bidirectional signal for two-way data binding. + + ## Examples + + iex> AshUI.Signal.Struct.bidirectional("User.name", "input-name") + """ + @spec bidirectional(String.t(), String.t(), keyword()) :: t() + def bidirectional(source_path, target, opts \\ []) do + source = parse_source_path(source_path) + + new( + id: Keyword.get(opts, :id), + source: source, + target: target, + type: :bidirectional, + transform: Keyword.get(opts, :transform), + metadata: Keyword.get(opts, :metadata, %{"direction" => "bidirectional"}) + ) + end + + @doc """ + Creates a collection signal for list binding. + + ## Examples + + iex> AshUI.Signal.Struct.collection("Post.comments", "list-comments") + """ + @spec collection(String.t(), String.t(), keyword()) :: t() + def collection(source_path, target, opts \\ []) do + source = parse_source_path(source_path) + + new( + id: Keyword.get(opts, :id), + source: source, + target: target, + type: :collection, + transform: Keyword.get(opts, :transform), + metadata: Keyword.get(opts, :metadata, %{"direction" => "collection"}) + ) + end + + @doc """ + Creates an event signal for action binding. + + ## Examples + + iex> AshUI.Signal.Struct.event("User.create", "button-submit") + """ + @spec event(String.t(), String.t(), keyword()) :: t() + def event(source_path, target, opts \\ []) do + source = parse_source_path(source_path) + + new( + id: Keyword.get(opts, :id), + source: source, + target: target, + type: :event, + transform: Keyword.get(opts, :transform), + metadata: Keyword.get(opts, :metadata, %{"direction" => "event"}) + ) + end + + @doc """ + Validates a signal struct. + + ## Returns + * `:ok` - Valid signal + * `{:error, reasons}` - List of validation errors + """ + @spec validate(t()) :: :ok | {:error, [String.t()]} + def validate(%__MODULE__{} = signal) do + errors = + [] + |> validate_id(signal) + |> validate_target(signal) + |> validate_type(signal) + |> validate_source(signal) + + if errors == [] do + :ok + else + {:error, errors} + end + end + + defp validate_id(errors, %__MODULE__{id: id}) when is_binary(id) and id != "", do: errors + defp validate_id(errors, _), do: ["Signal ID is required and must be a non-empty string" | errors] + + defp validate_target(errors, %__MODULE__{target: target}) when is_binary(target) and target != "", + do: errors + + defp validate_target(errors, _), do: ["Signal target is required and must be a non-empty string" | errors] + + defp validate_type(errors, %__MODULE__{type: type}) when type in [:bidirectional, :collection, :event], + do: errors + + defp validate_type(errors, _), do: ["Signal type must be :bidirectional, :collection, or :event" | errors] + + defp validate_source(errors, %__MODULE__{source: source}) when is_map(source) do + if Map.has_key?(source, "type") do + errors + else + ["Signal source must have a 'type' key" | errors] + end + end + + defp validate_source(errors, _), do: ["Signal source must be a map" | errors] + + # Parse source path string into source map + defp parse_source_path(path) when is_binary(path) do + case String.split(path, ".") do + [resource] -> + %{"type" => "resource", "resource" => resource} + + [resource, action] when action in ["create", "update", "delete"] -> + %{"type" => "action", "resource" => resource, "action" => action} + + [resource, field] -> + %{"type" => "field", "resource" => resource, "field" => field} + + parts -> + # Handle nested relationships + case parse_relationship_path(parts) do + {:ok, source} -> source + :error -> %{"type" => "path", "path" => path} + end + end + end + + defp parse_source_path(source) when is_map(source), do: normalize_source(source) + + defp parse_relationship_path([resource | relationship_parts]) do + { + :ok, + %{ + "type" => "relationship", + "resource" => resource, + "path" => relationship_parts + } + } + end + + # Normalize source map to ensure required fields + defp normalize_source(source) when is_map(source) do + Map.put_new(source, "type", "custom") + end + + # Generate unique signal ID + defp generate_id do + "signal_#{System.system_time(:millisecond)}_#{:rand.uniform(10000)}" + end +end 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 c625a90a..8419cea5 100644 --- a/specs/planning/phase-03-data-binding-and-signal-mapping.md +++ b/specs/planning/phase-03-data-binding-and-signal-mapping.md @@ -15,151 +15,151 @@ 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. - [ ] 3.1 Section - Binding Evaluation + [X] 3.1 Section - Binding Evaluation Implement runtime evaluation of bindings against Ash resource data. - [ ] 3.1.1 Task - Implement binding evaluator + [X] 3.1.1 Task - Implement binding evaluator Create the evaluator that resolves bindings to actual values. - [ ] 3.1.1.1 Subtask - Implement `AshUI.Runtime.BindingEvaluator.evaluate/3` - [ ] 3.1.1.2 Subtask - Accept binding, context (user_id, params), and socket assigns - [ ] 3.1.1.3 Subtask - Return `{:ok, value}` or `{:error, reason}` - [ ] 3.1.1.4 Subtask - Cache evaluated values for performance + [X] 3.1.1.1 Subtask - Implement `AshUI.Runtime.BindingEvaluator.evaluate/3` + [X] 3.1.1.2 Subtask - Accept binding, context (user_id, params), and socket assigns + [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 path resolution + [X] 3.1.2 Task - Implement source path resolution Resolve binding source paths to Ash resource attributes. - [ ] 3.1.2.1 Subtask - Parse source path (Domain.Resource.Attribute) - [ ] 3.1.2.2 Subtask - Load resource using `Ash.get/3` with proper authorization - [ ] 3.1.2.3 Subtask - Extract attribute value from loaded resource - [ ] 3.1.2.4 Subtask - Handle relationship traversal (e.g., `user.profile.name`) + [X] 3.1.2.1 Subtask - Parse source path (Domain.Resource.Attribute) + [X] 3.1.2.2 Subtask - Load resource using `Ash.get/3` with proper authorization + [X] 3.1.2.3 Subtask - Extract attribute value from loaded resource + [X] 3.1.2.4 Subtask - Handle relationship traversal (e.g., `user.profile.name`) - [ ] 3.1.3 Task - Implement transformation application + [X] 3.1.3 Task - Implement transformation application Apply transformation rules to resolved values. - [ ] 3.1.3.1 Subtask - Apply `format` transformations (e.g., date formatting) - [ ] 3.1.3.2 Subtask - Apply `compute` transformations (e.g., calculated fields) - [ ] 3.1.3.3 Subtask - Apply `default` transformations when source is nil - [ ] 3.1.3.4 Subtask - Apply `validate` transformations and return errors + [X] 3.1.3.1 Subtask - Apply `format` transformations (e.g., date formatting) + [X] 3.1.3.2 Subtask - Apply `compute` transformations (e.g., calculated fields) + [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. - [ ] 3.2.1 Task - Implement read direction + [X] 3.2.1 Task - Implement read direction Flow data from Ash resources to UI elements. - [ ] 3.2.1.1 Subtask - Subscribe to Ash resource changes - [ ] 3.2.1.2 Subtask - Re-evaluate binding on resource change - [ ] 3.2.1.3 Subtask - Update LiveView assigns on value change - [ ] 3.2.1.4 Subtask - Handle loading and error states + [X] 3.2.1.1 Subtask - Subscribe to Ash resource changes + [X] 3.2.1.2 Subtask - Re-evaluate binding on resource change + [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. - [ ] 3.2.2.1 Subtask - Capture user input events from LiveView - [ ] 3.2.2.2 Subtask - Validate input data before writing - [ ] 3.2.2.3 Subtask - Call `Ash.update/3` with new value - [ ] 3.2.2.4 Subtask - Handle update errors and display to user + [X] 3.2.2.1 Subtask - Capture user input events from LiveView + [X] 3.2.2.2 Subtask - Validate input data before writing + [X] 3.2.2.3 Subtask - Call `Ash.update/3` with new value + [X] 3.2.2.4 Subtask - Handle update errors and display to user - [ ] 3.2.3 Task - Implement conflict resolution + [X] 3.2.3 Task - Implement conflict resolution Handle concurrent updates to shared data. - [ ] 3.2.3.1 Subtask - Detect stale data with optimistic locking - [ ] 3.2.3.2 Subtask - Retry on conflict with backoff - [ ] 3.2.3.3 Subtask - Present conflict UI to user for resolution - [ ] 3.2.3.4 Subtask - Emit conflict telemetry events + [X] 3.2.3.1 Subtask - Detect stale data with optimistic locking + [X] 3.2.3.2 Subtask - Retry on conflict with backoff + [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. - [ ] 3.3.1.1 Subtask - Resolve collection source path - [ ] 3.3.1.2 Subtask - Use `Ash.read/2` to load collection - [ ] 3.3.1.3 Subtask - Apply pagination and filtering - [ ] 3.3.1.4 Subtask - Handle empty collections + [X] 3.3.1.1 Subtask - Resolve collection source path + [X] 3.3.1.2 Subtask - Use `Ash.read/2` to load collection + [X] 3.3.1.3 Subtask - Apply pagination and filtering + [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. - [ ] 3.3.2.1 Subtask - Subscribe to collection changes - [ ] 3.3.2.2 Subtask - Re-render list on collection modification - [ ] 3.3.2.3 Subtask - Handle insert, update, delete operations - [ ] 3.3.2.4 Subtask - Maintain scroll position during updates + [X] 3.3.2.1 Subtask - Subscribe to collection changes + [X] 3.3.2.2 Subtask - Re-render list on collection modification + [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. - [ ] 3.4.1.1 Subtask - Parse action source (Domain.Resource.action_name) - [ ] 3.4.1.2 Subtask - Call `Ash.action/3` 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.1 Subtask - Parse action source (Domain.Resource.action_name) + [X] 3.4.1.2 Subtask - Call `Ash.action/3` with event data + [X] 3.4.1.3 Subtask - Check authorization before execution + [X] 3.4.1.4 Subtask - Return action result to UI - [ ] 3.4.2 Task - Implement action event wiring + [X] 3.4.2 Task - Implement action event wiring Connect UI events to action bindings. - [ ] 3.4.2.1 Subtask - Generate event handler from binding definition - [ ] 3.4.2.2 Subtask - Wire handler to LiveView `handle_event/3` - [ ] 3.4.2.3 Subtask - Pass event data to action parameters - [ ] 3.4.2.4 Subtask - Handle action errors and display feedback + [X] 3.4.2.1 Subtask - Generate event handler from binding definition + [X] 3.4.2.2 Subtask - Wire handler to LiveView `handle_event/3` + [X] 3.4.2.3 Subtask - Pass event data to action parameters + [X] 3.4.2.4 Subtask - Handle action errors and display feedback - [ ] 3.5 Section - Signal Format Conversion + [X] 3.5 Section - Signal Format Conversion Convert Ash bindings to unified-ui signal format. - [ ] 3.5.1 Task - Define signal structure + [X] 3.5.1 Task - Define signal structure Create the signal structure matching unified-ui spec. - [ ] 3.5.1.1 Subtask - Implement `AshUI.Signal` struct with `id`, `source`, `target` fields - [ ] 3.5.1.2 Subtask - Add `type`, `transform`, `metadata` fields - [ ] 3.5.1.3 Subtask - Implement signal creation helpers - [ ] 3.5.1.4 Subtask - Add signal validation + [X] 3.5.1.1 Subtask - Implement `AshUI.Signal` struct with `id`, `source`, `target` fields + [X] 3.5.1.2 Subtask - Add `type`, `transform`, `metadata` fields + [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 Ensure signals are compatible with unified signal transport. - [ ] 3.5.2.1 Subtask - Wrap Ash signals in Jido.Signal structure - [ ] 3.5.2.2 Subtask - Use CloudEvents-compatible event format - [ ] 3.5.2.3 Subtask - Include required CloudEvents fields (id, source, type) - [ ] 3.5.2.4 Subtask - Add signal metadata for tracing + [X] 3.5.2.1 Subtask - Wrap Ash signals in Jido.Signal structure + [X] 3.5.2.2 Subtask - Use CloudEvents-compatible event format + [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. - [ ] 3.6.1 Task - Value binding integration scenarios + [X] 3.6.1 Task - Value binding integration scenarios Verify bidirectional value bindings work correctly. - [ ] 3.6.1.1 Subtask - Verify binding reads from Ash resource on mount - [ ] 3.6.1.2 Subtask - Verify binding updates on resource change - [ ] 3.6.1.3 Subtask - Verify user input writes back to Ash resource - [ ] 3.6.1.4 Subtask - Verify transformation rules apply correctly + [X] 3.6.1.1 Subtask - Verify binding reads from Ash resource on mount + [X] 3.6.1.2 Subtask - Verify binding updates on resource change + [X] 3.6.1.3 Subtask - Verify user input writes back to Ash resource + [X] 3.6.1.4 Subtask - Verify transformation rules apply correctly - [ ] 3.6.2 Task - List binding integration scenarios + [X] 3.6.2 Task - List binding integration scenarios Verify collection bindings work correctly. - [ ] 3.6.2.1 Subtask - Verify list loads and displays collection - [ ] 3.6.2.2 Subtask - Verify list updates on collection changes - [ ] 3.6.2.3 Subtask - Verify pagination and filtering work - [ ] 3.6.2.4 Subtask - Verify empty list state displays correctly + [X] 3.6.2.1 Subtask - Verify list loads and displays collection + [X] 3.6.2.2 Subtask - Verify list updates on collection changes + [X] 3.6.2.3 Subtask - Verify pagination and filtering work + [X] 3.6.2.4 Subtask - Verify empty list state displays correctly - [ ] 3.6.3 Task - Action binding integration scenarios + [X] 3.6.3 Task - Action binding integration scenarios Verify action bindings execute correctly. - [ ] 3.6.3.1 Subtask - Verify button click triggers Ash action - [ ] 3.6.3.2 Subtask - Verify action passes event data correctly - [ ] 3.6.3.3 Subtask - Verify authorization is checked - [ ] 3.6.3.4 Subtask - Verify action errors display to user + [X] 3.6.3.1 Subtask - Verify button click triggers Ash action + [X] 3.6.3.2 Subtask - Verify action passes event data correctly + [X] 3.6.3.3 Subtask - Verify authorization is checked + [X] 3.6.3.4 Subtask - Verify action errors display to user - [ ] 3.6.4 Task - Error handling integration scenarios + [X] 3.6.4 Task - Error handling integration scenarios Verify binding errors are handled gracefully. - [ ] 3.6.4.1 Subtask - Verify invalid source produces clear error - [ ] 3.6.4.2 Subtask - Verify unauthorized access is blocked - [ ] 3.6.4.3 Subtask - Verify transformation errors are surfaced - [ ] 3.6.4.4 Subtask - Verify action execution errors display feedback + [X] 3.6.4.1 Subtask - Verify invalid source produces clear error + [X] 3.6.4.2 Subtask - Verify unauthorized access is blocked + [X] 3.6.4.3 Subtask - Verify transformation errors are surfaced + [X] 3.6.4.4 Subtask - Verify action execution errors display feedback diff --git a/test/ash_ui/runtime/action_binding_test.exs b/test/ash_ui/runtime/action_binding_test.exs new file mode 100644 index 00000000..0125471f --- /dev/null +++ b/test/ash_ui/runtime/action_binding_test.exs @@ -0,0 +1,86 @@ +defmodule AshUI.Runtime.ActionBindingTest do + use ExUnit.Case, async: true + + alias AshUI.Runtime.ActionBinding + + describe "execute_action/4" do + setup do + context = %{ + user_id: "user-1", + params: %{}, + assigns: %{} + } + + binding = %{ + id: "action-binding-test", + source: %{"resource" => "User", "action" => "create"}, + target: "submit-button", + binding_type: :action + } + + %{binding: binding, context: context} + end + + test "executes action with event data" do + event_data = %{"name" => "John", "email" => "john@example.com"} + + assert {:ok, result} = ActionBinding.execute_action(@binding, event_data, @context) + assert result.status == :ok + assert result.data != nil + end + + test "returns error for unauthorized action" do + unauthorized_context = %{user_id: nil, params: %{}, assigns: %{}} + + assert {:error, _reason} = ActionBinding.execute_action(@binding, %{}, unauthorized_context) + end + end + + describe "event_handler/2" do + test "generates LiveView event handler" do + binding = %{ + id: "handler-test", + source: %{"resource" => "User", "action" => "delete"}, + target: "delete-button", + binding_type: :action + } + + handler = ActionBinding.event_handler(binding, "button-1") + + assert is_function(handler) + end + end + + describe "wire_handlers/2" do + test "creates handler map from action bindings" do + socket = %{assigns: %{}} + + bindings = [ + %{ + id: "action-1", + source: %{"resource" => "User", "action" => "create"}, + target: "create-btn", + binding_type: :action + }, + %{ + id: "action-2", + source: %{"resource" => "Post", "action" => "delete"}, + target: "delete-btn", + binding_type: :action + }, + # Non-action binding should be excluded + %{ + id: "value-1", + source: %{"resource" => "User", "field" => "name"}, + target: "name-input", + binding_type: :value + } + ] + + handlers = ActionBinding.wire_handlers(bindings, socket) + + assert map_size(handlers) == 2 + assert Enum.all?(handlers, fn {_, handler} -> is_function(handler) end) + end + end +end diff --git a/test/ash_ui/runtime/bidirectional_binding_test.exs b/test/ash_ui/runtime/bidirectional_binding_test.exs new file mode 100644 index 00000000..aa0fd49e --- /dev/null +++ b/test/ash_ui/runtime/bidirectional_binding_test.exs @@ -0,0 +1,87 @@ +defmodule AshUI.Runtime.BidirectionalBindingTest do + use AshUI.DataCase, async: false + + alias AshUI.Runtime.BidirectionalBinding + + describe "read_binding/2" do + test "reads binding value and updates socket assigns" do + socket = %Phoenix.LiveView.Socket{ + assigns: %{ash_ui: %{}} + } + + binding = %{ + id: "binding-read-test", + source: %{"resource" => "User", "field" => "name"}, + target: "name-input", + binding_type: :value + } + + context = %{user_id: "user-1", params: %{}, assigns: %{}} + + assert {:ok, updated_socket} = BidirectionalBinding.read_binding(binding, socket, context) + assert updated_socket != socket + end + end + + describe "write_binding/4" do + test "writes user input back to Ash resource" do + socket = %Phoenix.LiveView.Socket{ + assigns: %{ash_ui: %{}} + } + + binding = %{ + id: "binding-write-test", + source: %{"resource" => "User", "field" => "name"}, + target: "name-input", + binding_type: :value + } + + context = %{user_id: "user-1", params: %{}, assigns: %{}} + new_value = "Updated Name" + + assert {:ok, _socket, result} = BidirectionalBinding.write_binding(binding, new_value, socket, context) + assert result.status == :ok + end + + test "validates input before writing" do + socket = %Phoenix.LiveView.Socket{ + assigns: %{ash_ui: %{}} + } + + binding = %{ + id: "binding-validate-test", + source: %{"resource" => "User", "field" => "email"}, + target: "email-input", + binding_type: :value, + transform: %{"validate" => [%{"type" => "required"}]} + } + + context = %{user_id: "user-1", params: %{}, assigns: %{}} + + # Empty string should fail required validation + assert {:error, _reason, _socket} = BidirectionalBinding.write_binding(binding, "", socket, context) + end + end + + describe "subscribe_binding/3" do + test "subscribes to resource changes" do + socket = %Phoenix.LiveView.Socket{ + assigns: %{ash_ui: %{}} + } + + binding = %{ + id: "binding-subscribe-test", + source: %{"resource" => "User", "field" => "name"}, + target: "name-input", + binding_type: :value + } + + context = %{user_id: "user-1", params: %{}, assigns: %{}} + + assert {:ok, updated_socket} = BidirectionalBinding.subscribe_binding(binding, socket, context) + + subscriptions = get_in(updated_socket.assigns, [:ash_ui, :subscriptions]) + assert is_map(subscriptions) + end + end +end diff --git a/test/ash_ui/runtime/binding_evaluator_test.exs b/test/ash_ui/runtime/binding_evaluator_test.exs new file mode 100644 index 00000000..d8cfec46 --- /dev/null +++ b/test/ash_ui/runtime/binding_evaluator_test.exs @@ -0,0 +1,95 @@ +defmodule AshUI.Runtime.BindingEvaluatorTest do + use ExUnit.Case, async: true + + alias AshUI.Runtime.BindingEvaluator + + describe "evaluate/3" do + setup do + context = %{ + user_id: "user-123", + params: %{"screen_id" => "screen-1"}, + assigns: %{} + } + + %{context: context} + end + + test "evaluates field binding successfully", %{context: context} do + binding = %{ + source: %{"resource" => "User", "field" => "name"}, + target: "input-name", + binding_type: :value + } + + assert {:ok, value} = BindingEvaluator.evaluate(binding, context) + assert is_map(value) or is_binary(value) + end + + test "applies default transformation", %{context: context} do + binding = %{ + source: %{"resource" => "User", "field" => "nickname"}, + target: "input-nickname", + binding_type: :value, + transform: %{"function" => "default", "args" => ["Anonymous"]} + } + + # When field is nil or empty, should return default + assert {:ok, _value} = BindingEvaluator.evaluate(binding, context) + end + + test "applies format transformation", %{context: context} do + binding = %{ + source: %{"resource" => "User", "field" => "created_at"}, + target: "span-date", + binding_type: :value, + transform: %{"function" => "format"} + } + + assert {:ok, _value} = BindingEvaluator.evaluate(binding, context) + end + end + + describe "evaluate_batch/3" do + test "evaluates multiple bindings" do + context = %{user_id: "user-123", params: %{}, assigns: %{}} + + bindings = [ + %{ + id: "binding-1", + source: %{"resource" => "User", "field" => "name"}, + target: "name", + binding_type: :value + }, + %{ + id: "binding-2", + source: %{"resource" => "User", "field" => "email"}, + target: "email", + binding_type: :value + } + ] + + results = BindingEvaluator.evaluate_batch(bindings, context) + + assert Map.has_key?(results, "binding-1") + assert Map.has_key?(results, "binding-2") + end + end + + describe "source path resolution" do + test "resolves simple field path" do + source = %{"resource" => "User", "field" => "name"} + binding = %{source: source, target: "test", binding_type: :value} + context = %{user_id: "user-123", params: %{}, assigns: %{}} + + assert {:ok, _value} = BindingEvaluator.evaluate(binding, context) + end + + test "resolves relationship path" do + source = %{"resource" => "User", "relationship" => "profile.name"} + binding = %{source: source, target: "test", binding_type: :value} + context = %{user_id: "user-123", params: %{}, assigns: %{}} + + assert {:ok, _value} = BindingEvaluator.evaluate(binding, context) + end + end +end diff --git a/test/ash_ui/runtime/list_binding_test.exs b/test/ash_ui/runtime/list_binding_test.exs new file mode 100644 index 00000000..492d5c25 --- /dev/null +++ b/test/ash_ui/runtime/list_binding_test.exs @@ -0,0 +1,82 @@ +defmodule AshUI.Runtime.ListBindingTest do + use ExUnit.Case, async: true + + alias AshUI.Runtime.ListBinding + + describe "load_collection/3" do + setup do + context = %{user_id: "user-1", params: %{}, assigns: %{}} + + binding = %{ + id: "list-binding-test", + source: %{"resource" => "Post", "relationship" => "comments"}, + target: "comments-list", + binding_type: :list + } + + %{binding: binding, context: context} + end + + test "loads collection with pagination" do + assert {:ok, result} = ListBinding.load_collection(@binding, @context, page: 1, page_size: 20) + + assert is_list(result.items) + assert result.total > 0 + assert result.page == 1 + assert result.page_size == 20 + end + + test "handles empty collections" do + assert {:ok, result} = ListBinding.load_collection(@binding, @context, page: 999, page_size: 20) + + assert result.items == [] + assert result.has_next == false + end + end + + describe "handle_collection_change/5" do + setup do + socket = %Phoenix.LiveView.Socket{ + assigns: %{ash_ui: %{}} + } + + binding = %{ + id: "list-change-test", + source: %{"resource" => "Post", "relationship" => "comments"}, + target: "comments-list", + binding_type: :list + } + + context = %{user_id: "user-1", params: %{}, assigns: %{}} + + %{binding: binding, context: context, socket: socket} + end + + test "handles insert changes" do + change_data = %{"id" => "comment-123", "content" => "New comment"} + + assert {:ok, updated_socket, should_update} = + ListBinding.handle_collection_change(@binding, :insert, change_data, @socket, @context) + + assert should_update == true + end + + test "handles update changes" do + change_data = %{"id" => "comment-123", "content" => "Updated"} + + assert {:ok, updated_socket, should_update} = + ListBinding.handle_collection_change(@binding, :update, change_data, @socket, @context) + + assert should_update == true + end + + test "handles delete changes" do + change_data = %{"id" => "comment-123"} + + assert {:ok, updated_socket, should_update} = + ListBinding.handle_collection_change(@binding, :delete, change_data, @socket, @context) + + assert should_update == true + end + end +end diff --git a/test/ash_ui/signal/cloud_events_test.exs b/test/ash_ui/signal/cloud_events_test.exs new file mode 100644 index 00000000..accda488 --- /dev/null +++ b/test/ash_ui/signal/cloud_events_test.exs @@ -0,0 +1,96 @@ +defmodule AshUI.Signal.CloudEventsTest do + use ExUnit.Case, async: true + + alias AshUI.Signal.Struct + alias AshUI.Signal.CloudEvents + + describe "to_cloud_event/1" do + test "converts signal to CloudEvents format" do + signal = Struct.bidirectional("User.name", "input-name") + + cloud_event = CloudEvents.to_cloud_event(signal) + + assert cloud_event["id"] == signal.id + assert cloud_event["source"] == "ash-ui/User/name" + assert cloud_event["type"] == "ash_ui.signal.bidirectional" + assert cloud_event["datacontenttype"] == "application/json" + assert is_map(cloud_event["data"]) + end + + test "includes required CloudEvents fields" do + signal = Struct.event("Post.create", "create-btn") + + cloud_event = CloudEvents.to_cloud_event(signal) + + # Required CloudEvents fields + assert Map.has_key?(cloud_event, "id") + assert Map.has_key?(cloud_event, "source") + assert Map.has_key?(cloud_event, "type") + assert Map.has_key?(cloud_event, "datacontenttype") + assert Map.has_key?(cloud_event, "time") + end + + test "includes AshUI-specific metadata" do + signal = Struct.collection("Post.comments", "comments-list") + + cloud_event = CloudEvents.to_cloud_event(signal) + + assert Map.has_key?(cloud_event, "ashui") + assert cloud_event["ashui"]["target"] == "comments-list" + end + end + + describe "from_cloud_event/1" do + test "converts CloudEvents back to signal" do + original_signal = Struct.bidirectional("User.name", "input-name") + cloud_event = CloudEvents.to_cloud_event(original_signal) + + assert {:ok, signal} = CloudEvents.from_cloud_event(cloud_event) + assert signal.id == original_signal.id + assert signal.target == original_signal.target + assert signal.type == original_signal.type + end + + test "returns error for invalid CloudEvents" do + invalid_event = %{"id" => "test"} + + assert {:error, _reason} = CloudEvents.from_cloud_event(invalid_event) + end + end + + describe "batch/1" do + test "wraps multiple signals in batch envelope" do + signals = [ + Struct.bidirectional("User.name", "name"), + Struct.collection("Post.comments", "comments"), + Struct.event("User.delete", "delete") + ] + + batch = CloudEvents.batch(signals) + + assert batch["type"] == "ash_ui.signal.batch" + assert is_list(batch["data"]["events"]) + assert length(batch["data"]["events"]) == 3 + end + end + + describe "serialize/2" do + test "serializes signal to JSON" do + signal = Struct.bidirectional("User.name", "input-name") + + json = CloudEvents.serialize(signal, format: :json) + + assert is_binary(json) + assert {:ok, _decoded} = Jason.decode(json) + end + + test "serializes CloudEvents to text format" do + signal = Struct.event("Post.create", "btn") + + text = CloudEvents.serialize(signal, format: :text) + + assert is_binary(text) + assert String.contains?(text, "CloudEvent") + end + end +end diff --git a/test/ash_ui/signal/struct_test.exs b/test/ash_ui/signal/struct_test.exs new file mode 100644 index 00000000..7f5570f4 --- /dev/null +++ b/test/ash_ui/signal/struct_test.exs @@ -0,0 +1,84 @@ +defmodule AshUI.Signal.StructTest do + use ExUnit.Case, async: true + + alias AshUI.Signal.Struct + + describe "new/1" do + test "creates new signal with defaults" do + signal = Struct.new() + + assert signal.id != nil + assert signal.source == %{} + assert signal.target == "" + assert signal.type == :bidirectional + assert signal.metadata == %{} + end + + test "creates signal with custom options" do + signal = Struct.new( + id: "custom-id", + source: %{"type" => "field", "resource" => "User"}, + target: "input-1", + type: :collection + ) + + assert signal.id == "custom-id" + assert signal.target == "input-1" + assert signal.type == :collection + end + end + + describe "bidirectional/2" do + test "creates bidirectional signal from path" do + signal = Struct.bidirectional("User.name", "name-input") + + assert signal.type == :bidirectional + assert signal.target == "name-input" + assert signal.source["resource"] == "User" + assert signal.source["field"] == "name" + end + end + + describe "collection/2" do + test "creates collection signal from path" do + signal = Struct.collection("Post.comments", "comments-list") + + assert signal.type == :collection + assert signal.target == "comments-list" + assert signal.source["relationship"] == "comments" + end + end + + describe "event/2" do + test "creates event signal from path" do + signal = Struct.event("User.delete", "delete-button") + + assert signal.type == :event + assert signal.target == "delete-button" + assert signal.source["action"] == "delete" + end + end + + describe "validate/1" do + test "validates valid signal" do + signal = Struct.bidirectional("User.name", "input") + + assert :ok = Struct.validate(signal) + end + + test "returns errors for invalid signal" do + # Missing id + signal = %Struct{id: "", target: "test", type: :bidirectional, source: %{}} + + assert {:error, errors} = Struct.validate(signal) + assert length(errors) > 0 + end + + test "validates signal type" do + signal = %Struct{id: "test", target: "test", type: :invalid, source: %{}} + + assert {:error, errors} = Struct.validate(signal) + assert Enum.any?(errors, &(&1 =~ "type must be")) + end + end +end