README.md

# AshSDUI

Server-Driven UI for Phoenix LiveView applications backed by [Ash](https://hexdocs.pm/ash) resources. AshSDUI lets you define UI layouts as data — either in code or persisted in your database — and render them dynamically in LiveView without redeploying.

## Features

- Define UI layouts as composable trees of typed components
- Persist and edit layouts at runtime via an Ash-powered `UINode` resource
- Code-based layouts for static or config-driven screens
- Registry-based component discovery with automatic scanning
- ETS-backed cache with automatic invalidation when `UINode` records change
- GraphQL fragment metadata on components for schema-driven tooling
- Audit trail via `ash_paper_trail` on all `UINode` changes

## Installation

```elixir
def deps do
  [
    {:ash_sdui, "~> 0.1"},
    {:ash_postgres, "~> 2"},  # or your preferred Ash data layer
    {:phoenix_live_view, "~> 1"}
  ]
end
```

Configure the data layer for `AshSDUI.UINode`:

```elixir
# config/config.exs
config :ash_sdui, AshSDUI.UINode,
  data_layer: AshPostgres.DataLayer
```

## Core Concepts

### Components

A component is a Phoenix function component registered with AshSDUI. Declare one with `use AshSDUI.Component`:

```elixir
defmodule MyAppWeb.Components.Player.ScoreCard do
  use MyAppWeb, :live_component
  use AshSDUI.Component, fragment: """
    fragment PlayerScoreCardData on Player {
      displayName
      currentScore
      rank
    }
  """

  def render(assigns) do
    ~H"""
    <div class="score-card">
      <h2><%= @subject.display_name %></h2>
      <p>Score: <%= @subject.current_score %></p>
      <p>Rank: #<%= @subject.rank %></p>
    </div>
    """
  end
end
```

The component is automatically registered in `AshSDUI.Registry` under the name derived from its module (e.g., `"Player.ScoreCard@v1"`). Set `@version "v2"` before `use AshSDUI.Component` to override the default `v1`.

### Layouts

Layouts are named trees of component references. Define them in code:

```elixir
AshSDUI.Layout.register("player-dashboard", %AshSDUI.Layout.LayoutDef{
  name: "player-dashboard",
  root: %AshSDUI.Layout.Node{
    component: "Player.ScoreCard@v1",
    subject_resource: "MyApp.Game.Player",
    subject_id: "first",
    children: [
      %AshSDUI.Layout.Node{
        component: "Player.ActivityFeed@v1",
        region: :sidebar,
        order: 0
      }
    ]
  }
})
```

Or create them dynamically via `AshSDUI.UINode` Ash actions:

```elixir
AshSDUI.UINode
|> Ash.Changeset.for_create(:create, %{
  component_name: "Player.ScoreCard@v1",
  subject_resource: "MyApp.Game.Player",
  subject_id: player_id,
  region: :default,
  order: 0
})
|> Ash.create!()
```

### LiveView Integration

Add `use AshSDUI` to any LiveView. It injects a `mount/3` that resolves and renders the layout tree, and a `sdui_root/1` component for rendering it:

```elixir
defmodule MyAppWeb.Live.PlayerDashboard do
  use MyAppWeb, :live_view
  use AshSDUI, lookup: {:from_params, :name}

  def render(assigns) do
    ~H"""
    <%= if @__sdui_tree__ do %>
      <.sdui_root />
    <% else %>
      <div>Layout not found</div>
    <% end %>
    """
  end
end
```

The `:lookup` option controls how the layout name is resolved:

| Strategy                        | Example                  | Resolves to                 |
| ------------------------------- | ------------------------ | --------------------------- |
| `{:from_params, :name}`         | `?name=player-dashboard` | `"player-dashboard"`        |
| `{:static, "player-dashboard"}` | —                        | Always `"player-dashboard"` |

You can override `mount/3` after `use AshSDUI` to add your own socket assigns — the injected mount is declared `defoverridable`.

## UINode Resource

`AshSDUI.UINode` is an Ash resource that stores individual nodes of a dynamic layout.

### Attributes

| Attribute           | Type       | Notes                                                  |
| ------------------- | ---------- | ------------------------------------------------------ |
| `:id`               | `:uuid`    | Primary key                                            |
| `:component_name`   | `:string`  | Required. Pattern: `^[A-Za-z0-9\.]+@v\d+$`             |
| `:static_props`     | `:map`     | Default: `%{}`                                         |
| `:subject_resource` | `:string`  | Optional Ash resource module name                      |
| `:subject_id`       | `:uuid`    | Optional. Use `"first"` to resolve the first record    |
| `:region`           | `:atom`    | Default: `:default`                                    |
| `:order`            | `:integer` | Default: `0`                                           |
| `:status`           | `:atom`    | `:draft`, `:published`, `:archived`. Default: `:draft` |
| `:name`             | `:string`  | Optional human label                                   |
| `:parent_id`        | `:uuid`    | Optional. Points to parent `UINode`                    |

### Actions

| Action     | Type    | Notes                          |
| ---------- | ------- | ------------------------------ |
| `:read`    | read    | Default                        |
| `:create`  | create  | Accepts all attributes         |
| `:update`  | update  | Accepts all attributes         |
| `:destroy` | destroy | Default                        |
| `:publish` | update  | Sets `:status` to `:published` |
| `:revert`  | update  | Sets `:status` to `:archived`  |

### Audit Trail

All changes to `UINode` are tracked via `ash_paper_trail` in `:changes_only` mode. This gives you a full revision history out of the box.

## Caching

`AshSDUI.Cache` is an ETS-backed cache keyed on layout name. Rendered trees are cached after the first render and automatically evicted whenever a relevant `UINode` is created, updated, or destroyed (via `AshSDUI.Notifier`).

Manual cache operations:

```elixir
AshSDUI.Cache.get("player-dashboard")   # {:ok, tree} | {:error, :not_found}
AshSDUI.Cache.evict("player-dashboard") # :ok
AshSDUI.Cache.flush()                   # clears all entries
```

## Component Registry

`AshSDUI.Registry` holds all discovered components. It is backed by ETS (fast concurrent reads) plus `persistent_term` (survives ETS resets).

```elixir
AshSDUI.Registry.lookup("Player.ScoreCard@v1")
# {:ok, %{module: MyAppWeb.Components.Player.ScoreCard, name: "Player.ScoreCard@v1",
#          fragment: "fragment PlayerScoreCardData on Player { ... }",
#          subject_types: ["Player"]}}

AshSDUI.Registry.all()
# [%{module: ..., name: ..., fragment: ..., subject_types: [...]}, ...]

AshSDUI.Registry.discover_components()
# Scans all loaded OTP applications and registers any module using AshSDUI.Component
```

## Subject Resolution

When a `UINode` has a `:subject_resource` and `:subject_id`, `AshSDUI.Calculations.ResolveSubject.resolve/1` fetches the live Ash record and passes it to the component as `@subject`. Using `"first"` as the subject ID returns the first record from the resource.

## License

MIT