Riddler.Screens (Riddler v0.1.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.

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

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()} | {: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}

validate_responses(document, screen_key, responses)

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

validate_responses(document, screen_key, responses, pressed_button_key)

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