MishkaCmsCore.Builder.Action behaviour (Mishka CMS Core v0.0.1)

Copy Markdown View Source

What an element can be made to do when it is clicked — one declared thing, so a package can add one.

The contract

An action is a descriptor in the builder:interactions registry (ADR-14, see MishkaCmsCore.Builder.Events) whose run names a module implementing this behaviour. Core seeds seven; a plugin appends its own the way it appends an audit check:

use MishkaInstaller.Event.Hook, event: "builder:interactions", initial: %{priority: 50}

def call(%{actions: actions} = payload) do
  {:reply, %{payload | actions: actions ++ [MyPkg.Actions.Toast.descriptor()]}}
end

The descriptor is a plain map because it travels through a hook payload, and run is a module rather than a closure for the same reason — the rule MishkaCmsCore.Builder.Descriptor already keeps for every other registry.

Client or server, and nothing in between

An action says where it runs, and the two are not alike:

  • :client writes a Phoenix.LiveView.JS command. It runs in the browser with no round trip and survives patches because it is part of what the server rendered. Six of core's seven are this, and anything that only moves classes or visibility around belongs here.
  • :server writes JS.push/2 and names a handler. Use it only when something the server owns has to change — a flash lives on the socket, so "Show a message" has no client-side answer.

A server action also names the module the compiled page calls, as {module, function}. That module must be runtime code: it is named inside every published page that uses the action, and a visitor's page has no business naming anything under MishkaCmsCore.Builder.* or MishkaCmsCore.Runtime.PageBuilder.*. Core's own points at MishkaCmsCore.Runtime.PageMessage.

What the panel draws

needs/1 returns the boxes this action wants, and the Inspector renders them. A package never touches panel code and never ships markup for a form: it says it wants a line of text called message and gets a labelled input, remembered and read back.

Both directions are terms

write/3 answers a MishkaCmsCore.Builder.Command.t/0 — the command's name, its arguments and its options — and read/2 is handed one back. Neither side spells Elixir. What is written is escaped and printed by the code formatter, and what is read was parsed and unwrapped, so a class name containing a double quote is a class name containing a double quote in both directions and there is no place for a quoting mistake to live.

It is also what makes an option free: Phoenix.LiveView.JS validates the keys of whatever it is given, so transition:, time:, display: and blocking: work on the commands that take them without core holding a list of which those are.

Reading it back

read/2 is what lets the panel re-open an action the author already saved, with its boxes filled. The default answers :no, which is honest rather than clever: the panel then shows the expression as hand-written and refuses to rewrite something it does not understand. Implement it — a function head is usually enough — if you want yours to round-trip.

A whole action

defmodule MyPkg.Actions.Scroll do
  use MishkaCmsCore.Builder.Action

  alias MishkaCmsCore.Builder.Action

  def descriptor,
    do: action(key: "scroll", label: "Scroll to it", priority: 60)

  @impl true
  def needs(_action), do: [text("offset", "Offset", placeholder: "80")]

  @impl true
  def write(_action, context, values),
    do: {:dispatch, ["scroll-to"], [to: Action.aim(context), detail: values]}

  @impl true
  def read(_action, {:dispatch, ["scroll-to"], opts}), do: {:ok, opts[:detail]}
  def read(_action, _other), do: :no
end

Summary

Types

What the click already knows: the clicked element's own scope class, the scope it is aimed at (or nil for itself), and what the target says about being opened — see MishkaCmsCore.Builder.PaletteEntry.opens/1.

One box in the panel. :text is a line of text, :select a list of {value, label}.

What the author typed, keyed by field name.

Callbacks

A server action's event name — what JS.push/2 sends and the compiled page answers.

A server action's handler as {module, function}, called with (socket, params) and returning a socket. Runtime code only: it is named inside every published page using the action.

The boxes the panel draws for this action, in order.

Whether to offer this action at all, given what the click is aimed at.

Reads a command back into the values that wrote it, or :no if it is not this one's.

Where this action runs. :client is a JS command; :server is a push and a handler.

The command to write, a list of them to pipe, or nil when there is nothing to write yet.

Functions

A descriptor for this module's action. key is what the document stores and the select posts; label is what a reader sees; priority decides where it sits in the list.

The selector an action lands on: the target's scope when the click is aimed at one, and the clicked element's own otherwise.

This action's key, the string the document stores.

The module implementing this action, or nil for a descriptor that names none.

A list to pick from, as {value, label}. The first is what a fresh action starts on.

A line of text. placeholder is the example a non-technical author reads instead of a spec.

Types

context()

@type context() :: %{scope: String.t(), target: String.t() | nil, opener: map()}

What the click already knows: the clicked element's own scope class, the scope it is aimed at (or nil for itself), and what the target says about being opened — see MishkaCmsCore.Builder.PaletteEntry.opens/1.

field()

@type field() :: %{
  name: String.t(),
  label: String.t(),
  type: :text | :select,
  placeholder: String.t(),
  options: [{String.t(), String.t()}]
}

One box in the panel. :text is a line of text, :select a list of {value, label}.

values()

@type values() :: %{optional(String.t()) => String.t()}

What the author typed, keyed by field name.

Callbacks

event(action)

@callback event(action :: map()) :: String.t() | nil

A server action's event name — what JS.push/2 sends and the compiled page answers.

handler(action)

@callback handler(action :: map()) :: {module(), atom()} | nil

A server action's handler as {module, function}, called with (socket, params) and returning a socket. Runtime code only: it is named inside every published page using the action.

needs(action)

@callback needs(action :: map()) :: [field()]

The boxes the panel draws for this action, in order.

A field's name is how its value reaches write/3 and how read/2's answer is matched back to the box, so target, action and node_id are reserved — the panel's form already posts those three as the click's own context and one of your own by those names would be dropped.

offer?(action, context)

@callback offer?(action :: map(), context()) :: boolean()

Whether to offer this action at all, given what the click is aimed at.

read(action, t)

@callback read(action :: map(), MishkaCmsCore.Builder.Command.t()) ::
  {:ok, values()} | :no

Reads a command back into the values that wrote it, or :no if it is not this one's.

where(action)

@callback where(action :: map()) :: :client | :server

Where this action runs. :client is a JS command; :server is a push and a handler.

write(action, context, values)

@callback write(action :: map(), context(), values()) ::
  MishkaCmsCore.Builder.Command.t() | [MishkaCmsCore.Builder.Command.t()] | nil

The command to write, a list of them to pipe, or nil when there is nothing to write yet.

Terms, never source. Whatever is in the arguments and the options is escaped and printed for you, and Phoenix.LiveView.JS is asked whether the options are ones it takes before anything is stored.

Functions

action(opts)

(macro)

A descriptor for this module's action. key is what the document stores and the select posts; label is what a reader sees; priority decides where it sits in the list.

aim(context)

@spec aim(context()) :: String.t()

The selector an action lands on: the target's scope when the click is aimed at one, and the clicked element's own otherwise.

Sanitised down to what a class name can hold. Both scopes are minted by Tree.ensure_scope/2 and neither is an author's own word, so this changes nothing today — it is here so that a package reaching for the selector cannot be the first place a stray character lands in a generated selector.

key(action)

@spec key(map()) :: String.t()

This action's key, the string the document stores.

module(arg1)

@spec module(map()) :: module() | nil

The module implementing this action, or nil for a descriptor that names none.

select(name, label, options)

@spec select(String.t(), String.t(), [{String.t(), String.t()}]) :: field()

A list to pick from, as {value, label}. The first is what a fresh action starts on.

text(name, label, opts \\ [])

@spec text(String.t(), String.t(), keyword()) :: field()

A line of text. placeholder is the example a non-technical author reads instead of a spec.