Riddler.Screens (Riddler v0.2.0)

Copy Markdown View Source

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

resolve(document, root)

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 != ''"}]}

resolve_screen(document, screen_key, root)

@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}

validate_screen(document, screen_key, root)

@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 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, 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". 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, and a button that is not there carries the default, so a call through it reports the finding - unless the screen carries a button declaring validates as false and no key at all, which is a document Riddler.Screens.Document.validate/1 reports as defective.

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}

validate_screen(document, screen_key, root, pressed_button_key)

@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"