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.
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_responses/3 and validate_responses/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 build their own root from the responses they are handed, with an empty
context: a screen whose question is conditional on context resolves the
same way it would for a visitor the host knows nothing about, which hides
that question and so cannot fail it.
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()} | {:error, :no_such_screen}
Resolves the one screen a host is about to render.
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" => []}]
...> })
iex> {:ok, screen} = Riddler.Screens.resolve_screen(document, "account", %{})
iex> {screen.key, screen.title, screen.nodes}
{"account", "Create your account", []}
iex> Riddler.Screens.resolve_screen(document, "plan", %{})
{:error, :no_such_screen}
@spec validate_responses(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
these same responses first, 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.
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 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.
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 or response.out_of_range. There is one finding per
failing node, because an empty field is one thing wrong with a screen and
not three.
{: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_responses(document, "card", %{"billing_email" => "ada@example.com"})
:ok
iex> {:error, [finding]} = Riddler.Screens.validate_responses(document, "card", %{"billing_email" => "ada"})
iex> {finding.code, finding.node_key, finding.field}
{"response.format", "billing_email", "format"}
iex> {:error, [finding]} = Riddler.Screens.validate_responses(document, "card", %{})
iex> finding.code
"response.required"
iex> Riddler.Screens.validate_responses(document, "billing", %{})
{:error, :no_such_screen}
@spec validate_responses(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_responses/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_responses(document, "card", %{}, "card_back")
:ok
iex> {:error, [finding]} = Riddler.Screens.validate_responses(document, "card", %{}, "card_pay")
iex> finding.code
"response.required"