defmodule OctaStar.Phoenix.Controller do
@moduledoc """
Phoenix controller helpers for OctaStar.
Use `use OctaStar, :controller` from your web module after
`use Phoenix.Controller` has been applied:
def controller do
quote do
use Phoenix.Controller, formats: [:html]
use OctaStar, :controller
end
end
Controllers implement `OctaStar.StarView`:
@impl StarView
def handle_event(conn, "increment", signals), do: ...
## `assign/3` vs `signal/3`
- `assign/3` sets a Plug connection assign. Function components can read it
via `@key`, but it is never sent to the browser.
- `signal/3` does `assign/3` **and** tracks the key for automatic flushing.
Function components can read it, and the dispatcher sends it to the Datastar
client as a signal patch.
"""
import Plug.Conn
@signals_key :octa_star_signals_keys_and_opts
defmacro __using__(opts \\ []) do
auto_render? = Keyword.get(opts, :auto_render, true)
quote bind_quoted: [auto_render?: auto_render?] do
import OctaStar.Phoenix.Controller
alias OctaStar.StarView
@behaviour StarView
@doc false
def __octa_star_handler__, do: true
def render_html(conn) do
conn
|> Phoenix.Controller.put_view(html: __MODULE__)
|> Phoenix.Controller.render(:html)
end
def get(name_or_opts \\ []), do: OctaStar.Actions.get(__MODULE__, name_or_opts)
def get(event_name, opts), do: OctaStar.Actions.get(__MODULE__, event_name, opts)
def post(name_or_opts \\ []), do: OctaStar.Actions.post(__MODULE__, name_or_opts)
def post(event_name, opts), do: OctaStar.Actions.post(__MODULE__, event_name, opts)
def put(name_or_opts \\ []), do: OctaStar.Actions.put(__MODULE__, name_or_opts)
def put(event_name, opts), do: OctaStar.Actions.put(__MODULE__, event_name, opts)
def patch(name_or_opts \\ []), do: OctaStar.Actions.patch(__MODULE__, name_or_opts)
def patch(event_name, opts), do: OctaStar.Actions.patch(__MODULE__, event_name, opts)
def delete(name_or_opts \\ []), do: OctaStar.Actions.delete(__MODULE__, name_or_opts)
def delete(event_name, opts), do: OctaStar.Actions.delete(__MODULE__, event_name, opts)
if auto_render? do
def action(conn, opts) do
conn = super(conn, opts)
OctaStar.Phoenix.Controller.__maybe_auto_render__(__MODULE__, conn)
end
end
defoverridable action: 2, render_html: 1
end
end
@doc """
Assigns a value and tracks it as a Datastar signal.
"""
@spec signal(Plug.Conn.t(), atom(), term(), keyword()) :: Plug.Conn.t()
def signal(%Plug.Conn{} = conn, key, value, opts \\ []) when is_atom(key) do
conn
|> assign(key, value)
|> put_signal_key(key, opts)
end
@doc """
Patches a rendered component or HTML value against current assigns.
"""
@spec patch_element(Plug.Conn.t(), (map() -> term()) | term(), keyword()) :: Plug.Conn.t()
def patch_element(%Plug.Conn{} = conn, component_or_html, opts \\ []) do
opts =
case Keyword.pop(opts, :to) do
{nil, opts} -> opts
{to, opts} -> Keyword.put(opts, :selector, dom_id(to))
end
html =
if is_function(component_or_html, 1) do
component_or_html.(conn.assigns)
else
component_or_html
end
OctaStar.patch_elements(conn, html, opts)
end
@doc """
Flushes tracked signals as Datastar signal patch events.
"""
@spec flush_signals(Plug.Conn.t()) :: Plug.Conn.t()
def flush_signals(%Plug.Conn{} = conn) do
case extract_signals(conn) do
[] ->
conn
signals ->
Enum.reduce(signals, conn, fn {key, value, opts}, conn ->
OctaStar.patch_signals(conn, %{key => value}, opts)
end)
end
end
@doc """
Returns the tracked signal map as JSON for `data-signals`.
"""
@spec init_signals(Plug.Conn.t()) :: String.t() | nil
def init_signals(%Plug.Conn{} = conn) do
case extract_signals(conn) do
[] ->
nil
signals ->
signals
|> Enum.map(fn {key, value, _opts} -> {key, value} end)
|> Map.new()
|> OctaStar.JSON.encode!()
end
end
@doc """
Extracts tracked signal keys, values, and per-signal options.
"""
@spec extract_signals(Plug.Conn.t()) :: [{atom(), term(), keyword()}]
def extract_signals(%Plug.Conn{} = conn) do
conn.private
|> Map.get(@signals_key, [])
|> Enum.reverse()
|> Enum.map(fn {key, opts} -> {key, conn.assigns[key], opts} end)
end
@doc false
def __maybe_auto_render__(module, %Plug.Conn{state: :unset, halted: false} = conn) do
if function_exported?(module, :html, 1) do
conn
|> then(&apply(:"Elixir.Phoenix.Controller", :put_view, [&1, [html: module]]))
|> then(&apply(:"Elixir.Phoenix.Controller", :render, [&1, :html]))
else
conn
end
end
def __maybe_auto_render__(_module, conn), do: conn
@doc false
def dom_id("#" <> _ = id), do: id
def dom_id(id) when is_binary(id), do: "#" <> id
defp put_signal_key(%Plug.Conn{} = conn, key, opts) do
put_private(conn, @signals_key, [{key, opts} | Map.get(conn.private, @signals_key, [])])
end
end