MishkaCmsCore.Runtime.PageBuilder.Interactions (Mishka CMS Core v0.0.1)

Copy Markdown View Source

What an element does when it is clicked — the registry the panel reads and a package appends to.

Why not a script

Under LiveView the server owns the DOM: it sends a diff, and the client patches to match. A script that reaches in and changes a class is undone by the next patch that touches that element, which makes "add some JS to this button" the wrong shape of answer here even though it is the answer every WordPress builder gives.

Phoenix.LiveView.JS is the shape that fits. A command is declared in the markup, runs on the client with no round trip, and survives patches because it is part of what the server rendered. What the browser cannot do alone — a flash, which lives on the socket — takes the ordinary JS.push/2 route instead, and says which it is: see MishkaCmsCore.Builder.Action.

What is here, and what is not

This module owns the list and the rules everything on it must follow. It owns no verb. Each action is a module implementing MishkaCmsCore.Builder.Action, and core's seven arrive as the seed of the builder:interactions registry — the same ADR-14 event a plugin appends to, so a package's action reaches the panel through the door core already uses for its own.

That door was open for the list and shut for everything else: source/3 answered nil for any action outside a private table, so a package's entry appeared in the dropdown and then did nothing.

What it compiles to

Nothing invented — plain HEEx a developer could have written:

<button class="pb-7f3a91b2" phx-click={JS.toggle_class("is-open", to: ".pb-7f3a91b2")}>

What is checked

An action hands over terms rather than source — MishkaCmsCore.Builder.Command prints them — so what reaches a document is a JS call carrying data by construction, and there is no text of a package's to take on trust. What is still checked is that the command exists and that Phoenix.LiveView.JS accepts the options it was given; a contribution that fails either writes nothing and says why in the log.

Reading it back

By parsing the expression and asking each action whether it wrote it, never by matching text. An expression nothing claims — anything hand-written, anything piped into something else — is reported as :custom and left exactly as it is. The panel then shows it read-only rather than offering to rewrite something it does not understand, which is the contract Tree.Styles already keeps for a class attribute it cannot read.

Summary

Functions

The actions offered for a target, as {key, label} in panel order.

Whether this action takes a class name — the one field the audit and the tests still name.

The boxes the panel draws for one action, in order — see MishkaCmsCore.Builder.Action.

The attribute each open verb looks for on a component that declares its own open command.

Reads an attribute value back into {:ok, key, values}, :none, or :custom.

Every action the editor offers, core's first and a plugin's after them.

Core's own actions, in panel order — the seed every plugin appends to.

Whether this action reaches the server.

Every server action, as %{key:, event:, handler:} — what the page compiler needs and nothing else.

The expression source for one interaction, or nil when there is nothing to write.

The scope class an interaction points AT, or nil when it points at nothing named that way.

Functions

actions(opener \\ %{})

@spec actions(map()) :: [{String.t(), String.t()}]

The actions offered for a target, as {key, label} in panel order.

Why the list depends on what is being aimed at

A verb the target cannot honour is worse than a missing verb: it writes a command that runs, does something visible, and is not what the author asked for. Each action answers for itself through offer?/2 — a modal declaring open and close is offered Show and Hide, a navbar declaring only a toggle is offered only "Show or hide", and a component that declares nothing is offered everything, because there is no better guess to make.

class?(key)

@spec class?(String.t()) :: boolean()

Whether this action takes a class name — the one field the audit and the tests still name.

needs(key)

@spec needs(String.t()) :: [map()]

The boxes the panel draws for one action, in order — see MishkaCmsCore.Builder.Action.

This is what keeps a package out of panel code: it says it wants a line of text called message, and the Inspector renders a labelled input, remembers it and reads it back.

openers()

@spec openers() :: %{required(String.t()) => String.t()}

The attribute each open verb looks for on a component that declares its own open command.

read(arg1)

@spec read(term()) :: {:ok, String.t(), map()} | :none | :custom

Reads an attribute value back into {:ok, key, values}, :none, or :custom.

:custom is the important one: nothing in the registry claims the expression, so the panel must show it rather than replace it.

registry()

@spec registry() :: [map()]

Every action the editor offers, core's first and a plugin's after them.

Collected through builder:interactions, which de-duplicates by key so a plugin can add but never silently redefine a core verb. The call is a read of compiled plugin state, which is what the Inspector has always paid for this list.

seed()

@spec seed() :: [map()]

Core's own actions, in panel order — the seed every plugin appends to.

server?(key)

@spec server?(String.t()) :: boolean()

Whether this action reaches the server.

false is a Phoenix.LiveView.JS command and no round trip, which is six of core's seven. true is a push and a handler, and a page carrying one gets a handle_event/3 compiled into it.

server_actions()

@spec server_actions() :: [
  %{key: String.t(), event: String.t(), handler: {module(), atom()}}
]

Every server action, as %{key:, event:, handler:} — what the page compiler needs and nothing else.

An action missing either half is left out rather than half-compiled into somebody's page, and the handler is checked to be a function that exists: its module name is written into every published page using the action, so a typo would otherwise be a page that crashes on a click.

ONE ACTION PER EVENT. The compiler writes one handle_event/3 clause per event and a second for a name the first already matches is unreachable code in a published page, so the first wins — which, because MishkaCmsCore.Builder.Events keeps the core seed ahead of every contribution, means core's. A plugin answering to pb:say would be a plugin reading the messages every page on the site sends.

source(key, scope, opts \\ %{})

@spec source(String.t(), String.t(), map()) :: String.t() | nil

The expression source for one interaction, or nil when there is nothing to write.

opts carries what the click already knows and what the author typed: :target is another element's scope class (nil aims at the clicked element itself), :opener is what that target says about being opened, and every other key is a value from one of the action's own boxes.

Why a target at all

Because the commonest thing anyone wants from a button is to open something ELSE. A modal, a drawer and twelve more render their own content closed, and with to: pinned to the element being clicked, "open that dialog" was not a thing an author could ask for. target is another element's scope class and it is still a scope: the panel offers the nodes it knows about rather than a free-text selector, so an interaction can no more reach outside the document than it could before.

target(arg1)

@spec target(term()) :: String.t() | nil

The scope class an interaction points AT, or nil when it points at nothing named that way.

Read apart from read/1 rather than widened into it, because read/1's answer is what the panel and its tests are written against and the target is a different thing about the same expression.

The dot is dropped: everything else in the editor speaks in bare scope classes — Tree.ensure_scope/2 answers one, ElementCss keys on one — and a selector is this module's own spelling.