Riddler.Screens (Riddler v0.3.1)

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

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"