Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions changelog.d/sui-t36.6.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
### Added

- `StatifierUI.EventInjection.build/1` turns an `ADR-0003` fixture bundle
(or `nil`) into the event-injection pane model: a sorted palette of
editable event buttons via `StatifierUI.EventInjection.Palette`, a
`free_form_only?` flag for the fixture-less degraded mode, and
`send/2`/`send_draft/3` to deliver a `StatifierUI.EventInjection.Draft`
through `Statifier.Session.send_event/2` - the ordinary recordable input
path, per statifier ADR-0029.
662 changes: 662 additions & 0 deletions docs/plans/260822-sui-t36.6-event-injection-pane.md

Large diffs are not rendered by default.

101 changes: 101 additions & 0 deletions lib/statifier_ui/event_injection.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
defmodule StatifierUI.EventInjection do
@moduledoc """
The pane model behind the inspector's event-firing form: a palette built
from a fixture bundle's `events` map (`StatifierUI.EventInjection.Palette`),
a `free_form_only?` flag for the degraded mode the bead requires when there
are no fixtures to draw buttons from, and the one send path every submitted
form goes through.

## The one door in

Every send in this pane goes through `Statifier.Session.send_event/2` with
a `Statifier.Event.external/2` value - the ordinary recordable input path.
Per statifier ADR-0029, replay records the external event log as one of
its four recorded inputs; `send_event/2` is what keeps a recorded session
replayable, because it is the path the recording actually watches.
`Statifier.Session.interpret/2` and `send_invoked_event/3` are **not**
this pane's doors: `interpret/2` is the embedder seam ADR-0029 widened the
recording obligation for, and `send_invoked_event/3` is the child-to-parent
invoke direction, not an operator firing an event by hand. Neither is
called from anywhere in this module, or anywhere else in this pane.

`send_event/2` is a `GenServer.cast` - it returns `:ok` once the event is
enqueued, not once it has been processed. The effect of an injected event
(the dequeue, the transitions it triggers, the states it enters) shows up
on the trace stream (`StatifierUI.Trace.Subscriber`, `StatifierUI.EventLog`),
never on this module's return value.

## Degraded mode

`build/1` sets `free_form_only?: true` whenever the palette has no
entries: for `nil`, for a bundle whose `events` map is empty, and for a
bundle whose every event payload turned out to be unencodable (in which
case `diagnostics/1` says why). The free-form escape hatch
(`send_draft/3` with a name and payload text typed by hand) needs no
fixtures at all and always works, degraded mode or not.
"""

import Kernel, except: [send: 2]

alias Statifier.Event
alias Statifier.Session
alias StatifierUI.EventInjection.Draft
alias StatifierUI.EventInjection.Entry
alias StatifierUI.EventInjection.Palette
alias StatifierUI.Fixtures

@type t :: %__MODULE__{
palette: Palette.t(),
free_form_only?: boolean()
}

@enforce_keys [:palette, :free_form_only?]
defstruct [:palette, :free_form_only?]

@doc """
Builds a pane model from a fixture bundle, or from `nil` for the
fixture-less degraded mode.

Delegates to `StatifierUI.EventInjection.Palette.build/1` and returns
`{:error, {:invalid_fixtures, other}}` for the same inputs that function
rejects. `free_form_only?` is `true` exactly when the resulting palette
has no entries.
"""
@spec build(Fixtures.t() | nil) :: {:ok, t()} | {:error, term()}
def build(fixtures) do
with {:ok, palette} <- Palette.build(fixtures) do
{:ok, %__MODULE__{palette: palette, free_form_only?: palette.entries == []}}
end
end

@doc "The pane's palette entries, in event-name order."
@spec entries(t()) :: [Entry.t()]
def entries(%__MODULE__{palette: palette}), do: palette.entries

@doc "The pane's diagnostics: one per fixture event that could not be encoded."
@spec diagnostics(t()) :: [Fixtures.diagnostic()]
def diagnostics(%__MODULE__{palette: palette}), do: palette.diagnostics

@doc """
Delivers `event` to `server` through `Statifier.Session.send_event/2` and
nothing else - the only line in this pane, and in this repository's
`lib/`, that puts an event into a session.

`:ok` means enqueued, not processed; see the moduledoc.
"""
@spec send(Session.server(), Event.t()) :: :ok
def send(server, %Event{} = event), do: Session.send_event(server, event)

@doc """
Builds a draft from a form's name and payload text
(`StatifierUI.EventInjection.Draft.build/2`) and, on success, sends it
(`send/2`). A build failure is returned without touching `server` at all.
"""
@spec send_draft(Session.server(), String.t(), String.t() | nil) ::
:ok | {:error, Draft.reason()}
def send_draft(server, name, payload_text \\ nil) do
with {:ok, event} <- Draft.build(name, payload_text) do
send(server, event)
end
end
end
138 changes: 138 additions & 0 deletions lib/statifier_ui/event_injection/draft.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
defmodule StatifierUI.EventInjection.Draft do
@moduledoc """
Turns a form's two free-form fields - an event name and a payload text -
into a `Statifier.Event.t()`, the ordinary recordable input
(`Statifier.Event.external/2`) rather than a side door.

## The three-way payload distinction

`Statifier.Event`'s own moduledoc is explicit that `data` has three
states that must not collapse into each other: **no data** (`:undefined`),
**data, present, and null** (`nil`), and **data, present, and empty**
(`%{}`). One text field has to spell all three, and `build/2` resolves
them this way:

* **blank** (`nil`, or a string that is empty after trimming) means
`data: :undefined` - "no data" - the same default `Event.external/2`
itself uses when the caller passes no `:data` option at all.
* `"null"` decodes through `JSON.decode/1` to `nil`, which
`StatifierUI.Value.decode/1` passes through unchanged - "data,
present, and null".
* `"{}"` decodes to `%{}` the same way - "data, present, and empty".

Any other payload text is decoded as ADR-0005 JSON via `JSON.decode/1`
and then `StatifierUI.Value.decode/1`, so a `$`-tagged shape
(`$undefined`, `$date`, `$datetime`, `$duration`) resolves to the
predicator value it encodes.
"""

alias Statifier.Event
alias StatifierUI.Value

@typedoc """
Why `build/2` refused to produce an event.

* `:blank_event_name` - the name was empty, or all whitespace.
* `{:invalid_event_name, name}` - `name` was not a binary, or contained
whitespace or a control character (SCXML event names are
dot-delimited tokens; one with a space in it can never match a
transition's `event` attribute, so it is a typo rather than a
debugging choice).
* `{:invalid_json, reason}` - the payload text was not valid JSON;
`reason` is `JSON.decode/1`'s own error value.
* `{:invalid_payload, reason}` - the payload decoded as JSON but not as
an ADR-0005 value (an unknown `$`-prefixed tag, a malformed `$date`);
`reason` is `StatifierUI.Value.decode/1`'s own error value.
"""
@type reason ::
:blank_event_name
| {:invalid_event_name, term()}
| {:invalid_json, term()}
| {:invalid_payload, term()}

@doc """
Builds a `Statifier.Event.t()` from a form's name and payload text.

`name` is trimmed of leading/trailing whitespace before validation. An
unmatched but well-formed name is legal and is not rejected here - only
syntax is checked; whether the name matches any transition is the
chart's business, not this function's.

`payload_text` defaults to `nil`. `nil`, or a string that is blank after
trimming, means no data (`data: :undefined`); see the moduledoc for the
full three-way spelling.

No option other than `:data` is passed to `Statifier.Event.external/2`:
`invokeid`, `origin`, `origintype`, and `sendid` all belong to delivery
paths this function is not.

## Examples

iex> {:ok, event} = StatifierUI.EventInjection.Draft.build("payment.success", ~s({"amount":1999}))
iex> event.data
%{"amount" => 1999}

iex> {:ok, event} = StatifierUI.EventInjection.Draft.build("payment.success", "")
iex> event.data
:undefined

iex> StatifierUI.EventInjection.Draft.build("")
{:error, :blank_event_name}

"""
@spec build(String.t(), String.t() | nil) :: {:ok, Event.t()} | {:error, reason()}
def build(name, payload_text \\ nil)

def build(name, payload_text) when is_binary(name) do
trimmed_name = String.trim(name)

with :ok <- validate_name(trimmed_name),
{:ok, data} <- decode_payload(payload_text) do
{:ok, Event.external(trimmed_name, data: data)}
end
end

def build(other, _payload_text), do: {:error, {:invalid_event_name, other}}

@spec validate_name(String.t()) :: :ok | {:error, reason()}
defp validate_name(""), do: {:error, :blank_event_name}

defp validate_name(name) do
if String.match?(name, ~r/[\s\p{Cc}]/u) do
{:error, {:invalid_event_name, name}}
else
:ok
end
end

@spec decode_payload(String.t() | nil) :: {:ok, term()} | {:error, reason()}
defp decode_payload(nil), do: {:ok, :undefined}

defp decode_payload(text) when is_binary(text) do
case String.trim(text) do
"" ->
{:ok, :undefined}

trimmed ->
with {:ok, decoded} <- json_decode(trimmed) do
value_decode(decoded)
end
end
end

@spec json_decode(String.t()) :: {:ok, term()} | {:error, reason()}
defp json_decode(text) do
case JSON.decode(text) do
{:ok, decoded} -> {:ok, decoded}
{:error, reason} -> {:error, {:invalid_json, reason}}
end
end

@spec value_decode(term()) :: {:ok, term()} | {:error, reason()}
defp value_decode(decoded) do
case Value.decode(decoded) do
{:ok, value} -> {:ok, value}
{:error, reason} -> {:error, {:invalid_payload, reason}}
end
end
end
34 changes: 34 additions & 0 deletions lib/statifier_ui/event_injection/entry.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
defmodule StatifierUI.EventInjection.Entry do
@moduledoc """
One fixture event, rendered as a palette button: an event name, its sample
payload verbatim, and the ADR-0005 JSON text a form prefills the payload
field with.

## The `:undefined` asymmetry

`payload` is the fixture value exactly as `StatifierUI.Fixtures.event/2`
returned it - a predicator value, possibly the `:undefined` sentinel that
means "no data". `payload_text` is that value's canonical ADR-0005 JSON,
with one deliberate exception: a `payload` of `:undefined` produces
`payload_text: ""` rather than `~s({"$undefined":true})`.

The reason is the round trip. `StatifierUI.EventInjection.Draft.build/2`
reads a blank payload field as "no data" - the same spelling
`Statifier.Event.external/2` itself defaults to. Emitting the tagged JSON
text for `:undefined` would prefill a form field with text that, if sent
back unedited, still means "no data" but no longer looks blank to a person
editing it; emitting the blank string instead makes the palette's round
trip through the form the identity on the common case. Every other value,
`nil` included, gets its ordinary JSON text (`"null"` for `nil`, `"{}"` for
an empty map). This is the one place in the pane where the encoding and the
form affordance diverge, and it diverges on purpose.
"""

@type t :: %__MODULE__{
name: String.t(),
payload: term(),
payload_text: String.t()
}

defstruct [:name, :payload, :payload_text]
end
Loading
Loading