A document against one visitor: what they are shown, and what could not be decided.
Riddler.Screens.Document answers whether a document is well formed, on its
own, before anyone arrives. This module answers the other question: given a
host's context and a visitor's responses, which nodes are shown, what do
their templates say, and which container won. resolve/2 answers it for a
whole document and resolve_screen/3 for the one screen a host is about to
render.
Both are pure and total over anything an admitted document can hold: they
consult no store, raise on nothing, and report what they could not decide
rather than refusing it. Both report through the same pair of diagnostics:
resolve/2 carries them on the resolved document, resolve_screen/3 answers
with them beside the screen.
The root
The root is the map a condition and a template both read, and it has exactly
two keys: "context", what the host knows, and "responses", what the
visitor has submitted so far. Both are string-keyed, because both are decoded
JSON, and a response is addressed responses.<key> where the key is the key
of the question node it belongs to.
What resolution does to a node
A node with no condition is shown. A node whose condition evaluates false is
hidden silently, because that is the condition doing its job. A node whose
condition cannot be evaluated - a variable the root does not carry, an
operand of the wrong type, anything that is not true or false - is hidden
and reported in undecidable_conditions: the author asked a question the
runtime could not answer, and a visitor should not be shown a node on a
guess.
A container resolves to the first of its candidates whose condition holds, and the winner replaces the container, so nothing downstream needs to know a container was ever there. A container no candidate wins resolves to nothing.
Every template field - text, label and placeholder - is rendered
leniently: a variable the root does not carry renders as the empty string and
is reported in missing_variables, so a visitor sees a screen rather than an
error page. A node that is hidden is never rendered, so it contributes no
missing variables.
The output carries no condition anywhere, and carries writes through
untouched: what a button sets is the host's to apply when the visitor presses
it.
What a set of responses has to satisfy
validate_screen/3 and validate_screen/4 answer the last question:
whether what the visitor typed is enough to submit the screen. They resolve
the screen first and check only what came back, so a question a condition hid
cannot fail, and they consult what the node declares - required, format,
and min and max on the numeric formats. The arity-4 form takes the key of
the button the visitor pressed and honours its validates.
They take the same root resolve/2 and resolve_screen/3 take, and the
responses they check are the ones inside it. The screen validated is the
screen shown: a question the host's context made visible to this visitor
is a question this visitor can fail. A condition that root could not decide
is a finding rather than a pass wherever the press validates, because a node
hidden from the validator and not from the visitor is the one case this
cannot guess at.
iex> document =
...> Riddler.Screens.Document.admit(%{
...> "schema_version" => 1,
...> "id" => "edoc_checkout",
...> "screens" => [
...> %{
...> "key" => "card",
...> "title" => "Your card",
...> "nodes" => [
...> %{
...> "type" => "text",
...> "key" => "card_intro",
...> "text" => "Charging {{ context.tenant_name }}."
...> },
...> %{
...> "type" => "text",
...> "key" => "card_expiring",
...> "condition" => "context.card_expires_within_days < 30",
...> "text" => "The card on file expires soon."
...> }
...> ]
...> }
...> ]
...> })
iex> root = %{"context" => %{"tenant_name" => "Acme", "card_expires_within_days" => 9}}
iex> {:ok, resolved} = Riddler.Screens.resolve(document, root)
iex> Enum.map(hd(resolved.screens).nodes, & &1.text)
["Charging Acme.", "The card on file expires soon."]
iex> resolved.diagnostics
%{missing_variables: [], undecidable_conditions: []}
Summary
Functions
Resolves every screen of a document against a root.
Resolves the one screen a host is about to render.
Validates a visitor's responses against the screen they were shown.
Validates the responses the way the button the visitor pressed asks for.
Functions
@spec resolve(Riddler.Screens.Document.t(), map()) :: {:ok, Riddler.Screens.Resolved.t()}
Resolves every screen of a document against a root.
Always {:ok, resolved}: resolution reports rather than refuses, so a
condition it could not evaluate and a variable it could not find come back in
the resolved document's diagnostics.
The root is read for "context" and "responses" and nothing else; either
one absent is the same as it being empty.
iex> document =
...> Riddler.Screens.Document.admit(%{
...> "schema_version" => 1,
...> "id" => "edoc_signup",
...> "screens" => [
...> %{
...> "key" => "account",
...> "title" => "Create your account",
...> "nodes" => [
...> %{
...> "type" => "text",
...> "key" => "account_greeting",
...> "condition" => "responses.first_name != ''",
...> "text" => "Nice to meet you, {{ responses.first_name }}."
...> }
...> ]
...> }
...> ]
...> })
iex> {:ok, resolved} = Riddler.Screens.resolve(document, %{})
iex> {hd(resolved.screens).nodes, resolved.diagnostics.undecidable_conditions}
{[], [%{key: "account_greeting", condition: "responses.first_name != ''"}]}
@spec resolve_screen(Riddler.Screens.Document.t(), term(), map()) :: {:ok, Riddler.Screens.Resolved.screen(), Riddler.Screens.Resolved.diagnostics()} | {:error, :no_such_screen}
Resolves the one screen a host is about to render.
{:ok, screen, diagnostics}: the screen, and what resolving that screen
could not decide. The diagnostics are the shape resolve/2 carries on a
resolved document - missing_variables and undecidable_conditions, both in
document order - narrowed to the one screen. A caller is never handed a
screen whose resolution reported something without being handed the report,
which is what lets a single-screen host tell a node hidden by a condition
that held from a node hidden because the condition could not be decided.
Returns {:error, :no_such_screen} when the document declares no screen
under that key: asking for a screen that is not there is the host's mistake,
not a visitor's, and is worth an error rather than an empty screen.
iex> document =
...> Riddler.Screens.Document.admit(%{
...> "schema_version" => 1,
...> "id" => "edoc_signup",
...> "screens" => [
...> %{
...> "key" => "account",
...> "title" => "Create your account",
...> "nodes" => [
...> %{
...> "type" => "text",
...> "key" => "account_greeting",
...> "condition" => "responses.first_name != ''",
...> "text" => "Nice to meet you."
...> }
...> ]
...> }
...> ]
...> })
iex> {:ok, screen, diagnostics} = Riddler.Screens.resolve_screen(document, "account", %{})
iex> {screen.key, screen.title, screen.nodes}
{"account", "Create your account", []}
iex> diagnostics
%{
missing_variables: [],
undecidable_conditions: [
%{key: "account_greeting", condition: "responses.first_name != ''"}
]
}
iex> Riddler.Screens.resolve_screen(document, "plan", %{})
{:error, :no_such_screen}
@spec validate_screen(Riddler.Screens.Document.t(), term(), map()) :: :ok | {:error, [Riddler.Finding.t()]} | {:error, :no_such_screen}
Validates a visitor's responses against the screen they were shown.
:ok, or every reason the screen is not ready to be submitted. Checks run
over the resolved screen and nothing else: the screen is resolved against
the root the host resolved with, so a node a condition hid is a node the
visitor never saw and cannot be held to. A question the visitor was never
asked cannot fail.
The root is the one resolve/2 and resolve_screen/3 take, read for
"context" and "responses" and nothing else, and the responses that are
checked are the ones inside it. A screen validated is the screen shown: a
question a context condition made visible to this visitor is a question
this visitor can fail.
What is checked is what the node declares. required is unanswered when the
response is absent or is a string of whitespace. format names one of the
validation formats the internal Riddler.Screens.Document.formats/0 lists,
and a blank response that is not required is not put to it - a format has
nothing to say about text a visitor did not type. The numeric formats also
honour min and max where the question declares them. A question whose
pattern does not compile is not checked against that pattern here: the
defect is the document's and Riddler.Screens.Document.validate/1 reports it as
document.invalid_pattern, so a host that validates responses without
validating documents is not told, and every response to that question
satisfies the pattern check.
Every finding names the node it is about in node_key, the field that was
not satisfied in field, and a stable code: response.required,
response.format, response.out_of_range or response.undecidable. There
is one finding per failing node, because an empty field is one thing wrong
with a screen and not three.
response.undecidable is the one that is not about a response. A condition
this root could not decide - a variable the root does not carry, an operand
of the wrong type - is reported as a finding on the node that carries it,
with field "condition". So is a condition that is not valid predicator at
all, and one that is not a string: the code is the one fail-closed answer for
every condition resolution could not decide, so that a host which skipped
Riddler.Screens.Document.validate/1 is told rather than let through. The
message says which of the three it was, and carries the place the compiler
or the evaluator gave where there is one, as position does. A condition
that does not parse is also the document's own document.invalid_condition,
which is where an author fixing the document is told about it.
A condition that could not be decided says the
root the host handed in does not carry what the document asks about, which
is a defect in the call rather than a property of the visitor, so treating
the node as hidden and answering :ok would accept a submission nobody
checked. It is reported wherever the pressed button validates. The press
that does not is one through a button declaring validates as false,
which answers :ok without running a check at all - see
validate_screen/4. This arity-3 form presses no button, so there is no
press to read validates from and it reports the finding whatever buttons
the screen carries. A button declaring validates as false and carrying
no key at all is no exception: the opt-out is a property of a press, and
nothing can press a button nothing can name.
A condition this root decides false is a different thing and stays silent:
the node is hidden, and a question the visitor was never asked cannot fail.
So is a variable a template wanted and the root did not carry - that renders
as the empty string, is reported in missing_variables, and is no finding.
{:error, :no_such_screen} comes straight back from resolve_screen/3: a
host asking about a screen the document does not declare is told so rather
than told its responses are fine.
iex> document =
...> Riddler.Screens.Document.admit(%{
...> "schema_version" => 1,
...> "id" => "edoc_checkout",
...> "screens" => [
...> %{
...> "key" => "card",
...> "title" => "Your card",
...> "nodes" => [
...> %{
...> "type" => "text_question",
...> "key" => "billing_email",
...> "label" => "Where should the receipt go?",
...> "required" => true,
...> "format" => "email"
...> }
...> ]
...> }
...> ]
...> })
iex> Riddler.Screens.validate_screen(document, "card", %{"responses" => %{"billing_email" => "ada@example.com"}})
:ok
iex> {:error, [finding]} = Riddler.Screens.validate_screen(document, "card", %{"responses" => %{"billing_email" => "ada"}})
iex> {finding.code, finding.node_key, finding.field}
{"response.format", "billing_email", "format"}
iex> {:error, [finding]} = Riddler.Screens.validate_screen(document, "card", %{})
iex> finding.code
"response.required"
iex> Riddler.Screens.validate_screen(document, "billing", %{})
{:error, :no_such_screen}
@spec validate_screen(Riddler.Screens.Document.t(), term(), map(), term()) :: :ok | {:error, [Riddler.Finding.t()]} | {:error, :no_such_screen}
Validates the responses the way the button the visitor pressed asks for.
The same checks as validate_screen/3, with one addition: the pressed
button's validates. It defaults to true, so a button that says nothing
validates the screen it submits; a button that declares false answers
:ok without running a check, which is what lets a Back button leave a
half-filled screen. A key that names no button on the resolved screen
validates too, because the default is what a button that is not there
carries.
iex> screen = %{
...> "key" => "card",
...> "title" => "Your card",
...> "nodes" => [
...> %{"type" => "text_question", "key" => "billing_email", "label" => "Receipt to", "required" => true},
...> %{"type" => "button", "key" => "card_back", "label" => "Back", "outcome" => "went_back", "validates" => false},
...> %{"type" => "button", "key" => "card_pay", "label" => "Pay", "outcome" => "paid"}
...> ]
...> }
iex> document =
...> Riddler.Screens.Document.admit(%{
...> "schema_version" => 1,
...> "id" => "edoc_checkout",
...> "screens" => [screen]
...> })
iex> Riddler.Screens.validate_screen(document, "card", %{}, "card_back")
:ok
iex> {:error, [finding]} = Riddler.Screens.validate_screen(document, "card", %{}, "card_pay")
iex> finding.code
"response.required"