AshSDUI

Server-Driven UI for Phoenix LiveView applications backed by 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

Installation

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:

# 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:

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:

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:

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:

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:

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).

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