diff --git a/packages/live_ui/lib/live_ui/component.ex b/packages/live_ui/lib/live_ui/component.ex index 0166f3e4..ce422716 100644 --- a/packages/live_ui/lib/live_ui/component.ex +++ b/packages/live_ui/lib/live_ui/component.ex @@ -129,8 +129,7 @@ defmodule LiveUi.Component do ) component_module = component_module(module) - # TODO: Add widget-local state when Runtime.State.widget_local_state/2 is implemented - widget_local_state = nil + widget_local_state = fetch_widget_local_state(runtime_state, widget_identity) ~H""" <.live_component @@ -146,6 +145,12 @@ defmodule LiveUi.Component do """ end + defp fetch_widget_local_state(%RuntimeState{} = runtime_state, widget_identity) do + RuntimeState.widget_local_state(runtime_state, widget_identity) + end + + defp fetch_widget_local_state(_other, _widget_identity), do: %{} + defp runtime_mode(%RuntimeState{mode: mode}), do: mode defp runtime_mode(_other), do: :native diff --git a/packages/live_ui/lib/live_ui/runtime/error.ex b/packages/live_ui/lib/live_ui/runtime/error.ex index f947e840..1e520a17 100644 --- a/packages/live_ui/lib/live_ui/runtime/error.ex +++ b/packages/live_ui/lib/live_ui/runtime/error.ex @@ -47,4 +47,22 @@ defmodule LiveUi.Runtime.Error do details: %{screen: inspect(screen), route: route, result: inspect(result)} } end + + @spec widget_event_failed(term()) :: t() + def widget_event_failed(reason) do + %__MODULE__{ + reason: :widget_event_failed, + message: "widget event handler failed", + details: %{reason: inspect(reason)} + } + end + + @spec invalid_widget_event_payload() :: t() + def invalid_widget_event_payload do + %__MODULE__{ + reason: :invalid_widget_event_payload, + message: "widget event payload must contain widget_component, widget_key, and widget_event", + details: nil + } + end end diff --git a/packages/live_ui/lib/live_ui/runtime/screen_component.ex b/packages/live_ui/lib/live_ui/runtime/screen_component.ex index a4ddde7a..630dc87c 100644 --- a/packages/live_ui/lib/live_ui/runtime/screen_component.ex +++ b/packages/live_ui/lib/live_ui/runtime/screen_component.ex @@ -101,6 +101,29 @@ defmodule LiveUi.Runtime.ScreenComponent do @impl true def handle_event(event, params, socket) when is_binary(event) and is_map(params) do + case event do + "widget_component_event" -> + handle_widget_component_event(params, socket) + + _other -> + handle_screen_event(event, params, socket) + end + end + + defp handle_widget_component_event(params, socket) do + case State.handle_widget_event(socket.assigns.runtime_state, params) do + {:ok, updated_runtime_state} -> + {:noreply, + socket + |> assign(:runtime_state, updated_runtime_state) + |> assign(:runtime_event_error, nil)} + + {:error, reason} -> + {:noreply, assign(socket, :runtime_event_error, reason)} + end + end + + defp handle_screen_event(event, params, socket) do case State.handle_event(socket.assigns.runtime_state, event, params) do {:ok, updated_runtime_state} -> {:noreply, diff --git a/packages/live_ui/lib/live_ui/runtime/state.ex b/packages/live_ui/lib/live_ui/runtime/state.ex index c5d3cc1d..3ed37f24 100644 --- a/packages/live_ui/lib/live_ui/runtime/state.ex +++ b/packages/live_ui/lib/live_ui/runtime/state.ex @@ -1,21 +1,28 @@ defmodule LiveUi.Runtime.State do @moduledoc """ Server-authoritative runtime state for mounted `live_ui` screens. + + Widget-local state is bounded to specific widget component instances and + remains subordinate to server-authoritative screen or application state. """ alias LiveUi.Runtime.{BrowserBridge, Error} + alias LiveUi.Widget.Identity @enforce_keys [:screen, :assigns, :mode, :event_routes, :bridge_hooks] - defstruct [:screen, :assigns, :mode, :event_routes, :bridge_hooks] + defstruct [:screen, :assigns, :mode, :event_routes, :bridge_hooks, widget_local_state: %{}] @type mode :: :native | :canonical + @type widget_local_state :: %{optional(Identity.key()) => map()} + @type t :: %__MODULE__{ screen: module(), assigns: map(), mode: mode(), event_routes: %{optional(String.t()) => atom()}, - bridge_hooks: [atom()] + bridge_hooks: [atom()], + widget_local_state: widget_local_state() } @spec mount(module(), keyword()) :: {:ok, t()} | {:error, Error.t()} @@ -74,4 +81,126 @@ defmodule LiveUi.Runtime.State do defp normalize_defaults(_screen, defaults) when is_map(defaults), do: {:ok, defaults} defp normalize_defaults(screen, _other), do: {:error, Error.invalid_mount_defaults(screen)} + + @doc """ + Gets the widget-local state for a given widget identity. + + Returns the stored widget-local state map, or an empty map if none exists. + """ + @spec widget_local_state(t(), Identity.key() | Identity.t()) :: map() + def widget_local_state(%__MODULE__{} = state, widget_key) when is_binary(widget_key) do + Map.get(state.widget_local_state, widget_key, %{}) + end + + def widget_local_state(%__MODULE__{} = state, %Identity{} = widget_identity) do + widget_local_state(state, Identity.key(widget_identity)) + end + + @doc """ + Puts widget-local state for a given widget identity. + + Replaces any existing widget-local state for the widget with the provided map. + """ + @spec put_widget_local_state(t(), Identity.key() | Identity.t(), map()) :: t() + def put_widget_local_state(%__MODULE__{} = state, widget_key, local_state) + when is_binary(widget_key) and is_map(local_state) do + %{state | widget_local_state: Map.put(state.widget_local_state, widget_key, local_state)} + end + + def put_widget_local_state(%__MODULE__{} = state, %Identity{} = widget_identity, local_state) do + put_widget_local_state(state, Identity.key(widget_identity), local_state) + end + + @doc """ + Updates widget-local state for a given widget identity using a function. + + The function receives the current widget-local state (or an empty map if none exists) + and must return the updated widget-local state map. + """ + @spec update_widget_local_state(t(), Identity.key() | Identity.t(), (map() -> map())) :: t() + def update_widget_local_state(%__MODULE__{} = state, widget_key, fun) + when is_binary(widget_key) and is_function(fun, 1) do + current_state = Map.get(state.widget_local_state, widget_key, %{}) + updated_state = fun.(current_state) + put_widget_local_state(state, widget_key, updated_state) + end + + def update_widget_local_state(%__MODULE__{} = state, %Identity{} = widget_identity, fun) do + update_widget_local_state(state, Identity.key(widget_identity), fun) + end + + @doc """ + Deletes widget-local state for a given widget identity. + + This is useful for cleanup when a widget is unmounted. + """ + @spec delete_widget_local_state(t(), Identity.key() | Identity.t()) :: t() + def delete_widget_local_state(%__MODULE__{} = state, widget_key) when is_binary(widget_key) do + %{state | widget_local_state: Map.delete(state.widget_local_state, widget_key)} + end + + def delete_widget_local_state(%__MODULE__{} = state, %Identity{} = widget_identity) do + delete_widget_local_state(state, Identity.key(widget_identity)) + end + + @doc """ + Handles a widget-targeted event by routing it to the widget component's + handle_widget_event callback and updating the widget-local state. + + The event payload should contain: + - "widget_component" - The widget component module (as a string from inspect/1) + - "widget_key" - The widget identity key + - "widget_event" - The event name to route to the widget + + Returns {:ok, updated_state} on success, {:error, reason} on failure. + """ + @spec handle_widget_event(t(), map()) :: {:ok, t()} | {:error, Error.t()} + def handle_widget_event(%__MODULE__{} = state, %{ + "widget_component" => widget_component_str, + "widget_key" => widget_key, + "widget_event" => widget_event + }) do + with {:ok, widget_component} <- parse_widget_component(widget_component_str), + {:ok, current_local_state} <- fetch_widget_local_state(state, widget_key), + {:ok, updated_local_state} <- + apply_widget_event(widget_component, widget_event, %{}, current_local_state) do + {:ok, put_widget_local_state(state, widget_key, updated_local_state)} + else + {:error, %Error{} = error} -> {:error, error} + {:error, reason} -> {:error, Error.widget_event_failed(reason)} + end + end + + def handle_widget_event(%__MODULE__{}, _other) do + {:error, Error.invalid_widget_event_payload()} + end + + defp parse_widget_component(widget_component_str) when is_binary(widget_component_str) do + try do + {:ok, Code.eval_string(widget_component_str) |> elem(0)} + rescue + _ -> {:error, :invalid_widget_component} + end + end + + defp fetch_widget_local_state(%__MODULE__{} = state, widget_key) do + {:ok, Map.get(state.widget_local_state, widget_key, %{})} + end + + defp apply_widget_event(widget_component, event, payload, local_state) do + # Convert event string to atom for the callback + event_atom = if is_binary(event), do: String.to_existing_atom(event), else: event + + if function_exported?(widget_component, :handle_widget_event, 3) do + case widget_component.handle_widget_event(event_atom, payload, local_state) do + {:ok, updated_state} when is_map(updated_state) -> {:ok, updated_state} + {:error, reason} -> {:error, reason} + other -> {:error, {:invalid_event_result, other}} + end + else + {:error, :widget_component_not_implemented} + end + rescue + ArgumentError -> {:error, :invalid_event_name} + end end diff --git a/packages/live_ui/test/live_ui/widget_local_state_test.exs b/packages/live_ui/test/live_ui/widget_local_state_test.exs new file mode 100644 index 00000000..be965f2c --- /dev/null +++ b/packages/live_ui/test/live_ui/widget_local_state_test.exs @@ -0,0 +1,284 @@ +defmodule LiveUi.WidgetLocalStateTest do + use ExUnit.Case, async: true + use Phoenix.Component + + alias LiveUi.Runtime.State + alias LiveUi.Widget.Identity + alias LiveUi.Component.Metadata + + describe "widget_local_state/2" do + test "returns empty map when no state exists for widget" do + state = %State{screen: TestScreen, assigns: %{}, mode: :native, event_routes: %{}, bridge_hooks: []} + + assert State.widget_local_state(state, "native:content:text:test-id:root") == %{} + end + + test "returns stored state for widget identity key" do + widget_key = "native:content:text:test-id:root" + state = %State{ + screen: TestScreen, + assigns: %{}, + mode: :native, + event_routes: %{}, + bridge_hooks: [], + widget_local_state: %{widget_key => %{count: 5}} + } + + assert State.widget_local_state(state, widget_key) == %{count: 5} + end + + test "returns stored state for widget identity struct" do + identity = %Identity{ + id: "test-id--root", + component_module: TestWidget.Component, + widget_module: TestWidget, + family: :content, + name: :text, + path: [], + mode: :native + } + + state = %State{ + screen: TestScreen, + assigns: %{}, + mode: :native, + event_routes: %{}, + bridge_hooks: [], + widget_local_state: %{Identity.key(identity) => %{count: 5}} + } + + assert State.widget_local_state(state, identity) == %{count: 5} + end + end + + describe "put_widget_local_state/3" do + test "stores widget-local state by key" do + state = %State{screen: TestScreen, assigns: %{}, mode: :native, event_routes: %{}, bridge_hooks: []} + widget_key = "native:content:text:test-id:root" + + updated_state = State.put_widget_local_state(state, widget_key, %{count: 1}) + + assert updated_state.widget_local_state[widget_key] == %{count: 1} + end + + test "replaces existing widget-local state" do + widget_key = "native:content:text:test-id:root" + state = %State{ + screen: TestScreen, + assigns: %{}, + mode: :native, + event_routes: %{}, + bridge_hooks: [], + widget_local_state: %{widget_key => %{count: 1}} + } + + updated_state = State.put_widget_local_state(state, widget_key, %{count: 10}) + + assert updated_state.widget_local_state[widget_key] == %{count: 10} + end + + test "stores state for widget identity struct" do + state = %State{screen: TestScreen, assigns: %{}, mode: :native, event_routes: %{}, bridge_hooks: []} + identity = %Identity{ + id: "test-id--root", + component_module: TestWidget.Component, + widget_module: TestWidget, + family: :content, + name: :text, + path: [], + mode: :native + } + + updated_state = State.put_widget_local_state(state, identity, %{active: true}) + + assert updated_state.widget_local_state[Identity.key(identity)] == %{active: true} + end + end + + describe "update_widget_local_state/3" do + test "updates widget-local state with a function" do + widget_key = "native:content:button:counter:root" + state = %State{ + screen: TestScreen, + assigns: %{}, + mode: :native, + event_routes: %{}, + bridge_hooks: [], + widget_local_state: %{widget_key => %{count: 0}} + } + + updated_state = State.update_widget_local_state(state, widget_key, fn state -> + Map.update(state, :count, 0, &(&1 + 1)) + end) + + assert updated_state.widget_local_state[widget_key] == %{count: 1} + end + + test "creates empty state when none exists" do + widget_key = "native:content:button:new:root" + state = %State{screen: TestScreen, assigns: %{}, mode: :native, event_routes: %{}, bridge_hooks: []} + + updated_state = State.update_widget_local_state(state, widget_key, fn state -> + Map.put(state, :initialized, true) + end) + + assert updated_state.widget_local_state[widget_key] == %{initialized: true} + end + + test "works with widget identity struct" do + identity = %Identity{ + id: "toggle--root", + component_module: TestWidget.Component, + widget_module: TestWidget, + family: :input, + name: :toggle, + path: [], + mode: :native + } + + state = %State{ + screen: TestScreen, + assigns: %{}, + mode: :native, + event_routes: %{}, + bridge_hooks: [], + widget_local_state: %{Identity.key(identity) => %{checked: false}} + } + + updated_state = State.update_widget_local_state(state, identity, fn state -> + Map.update(state, :checked, false, &(!&1)) + end) + + assert updated_state.widget_local_state[Identity.key(identity)] == %{checked: true} + end + end + + describe "delete_widget_local_state/2" do + test "removes widget-local state by key" do + widget_key = "native:content:text:temp:root" + state = %State{ + screen: TestScreen, + assigns: %{}, + mode: :native, + event_routes: %{}, + bridge_hooks: [], + widget_local_state: %{widget_key => %{value: 1}} + } + + updated_state = State.delete_widget_local_state(state, widget_key) + + assert Map.has_key?(updated_state.widget_local_state, widget_key) == false + end + + test "works with widget identity struct" do + identity = %Identity{ + id: "removed--root", + component_module: TestWidget.Component, + widget_module: TestWidget, + family: :content, + name: :text, + path: [], + mode: :native + } + + state = %State{ + screen: TestScreen, + assigns: %{}, + mode: :native, + event_routes: %{}, + bridge_hooks: [], + widget_local_state: %{Identity.key(identity) => %{data: "test"}} + } + + updated_state = State.delete_widget_local_state(state, identity) + + assert Map.has_key?(updated_state.widget_local_state, Identity.key(identity)) == false + end + end + + describe "handle_widget_event/2" do + test "handles widget event and updates local state" do + widget_key = "native:content:button:counter:root" + state = %State{ + screen: TestScreen, + assigns: %{}, + mode: :native, + event_routes: %{}, + bridge_hooks: [], + widget_local_state: %{widget_key => %{count: 0}} + } + + # Use the wrapper module, not the Component submodule + # handle_widget_event/3 is defined in the wrapper module + params = %{ + "widget_component" => "Elixir.LiveUi.WidgetLocalStateTest.CounterWidget", + "widget_key" => widget_key, + "widget_event" => "increment" + } + + assert {:ok, updated_state} = State.handle_widget_event(state, params) + assert updated_state.widget_local_state[widget_key] == %{count: 1} + end + + test "returns error for invalid payload" do + state = %State{screen: TestScreen, assigns: %{}, mode: :native, event_routes: %{}, bridge_hooks: []} + + assert {:error, _reason} = State.handle_widget_event(state, %{}) + assert {:error, _reason} = State.handle_widget_event(state, %{"widget_key" => "test"}) + end + + test "returns error for non-existent widget component" do + state = %State{screen: TestScreen, assigns: %{}, mode: :native, event_routes: %{}, bridge_hooks: []} + + params = %{ + "widget_component" => "Elixir.NonExistent.Widget", + "widget_key" => "test", + "widget_event" => "event" + } + + assert {:error, _reason} = State.handle_widget_event(state, params) + end + end + + # Test helpers + + defmodule TestScreen do + def id, do: :test_screen + def mount_defaults, do: %{} + def event_routes, do: %{} + def bridge_hooks, do: [] + def handle_event(_event, _payload, _assigns), do: {:ok, %{}} + def render(_assigns), do: {:ok, %{}} + end + + defmodule TestWidget do + use LiveUi.Widget, + wrapper: __MODULE__, + family: :content, + name: :text, + assigns: [], + events: [], + local_state_keys: [] + end + + defmodule CounterWidget do + use LiveUi.Widget, + wrapper: __MODULE__, + family: :content, + name: :counter, + assigns: [:label], + events: [:click], + local_state_keys: [:count] + + @impl true + def mount_defaults, do: %{count: 0} + + @impl true + def event_routes, do: %{"increment" => :increment} + + @impl true + def handle_widget_event(:increment, _payload, local_state) do + {:ok, Map.update(local_state, :count, 0, &(&1 + 1))} + end + end +end