tea_dom_flags

The whole io surface of the demo: one call, exactly as tea_dom_counter's, with `serve_with_flags` in place of `serve`.

examples/projects/tea_dom_flags/src/main.dawn

# The whole io surface of the demo: one call, exactly as tea_dom_counter's,
# with `serve_with_flags` in place of `serve`.
#
# The difference is the first argument. `serve` takes the initial model;
# `serve_with_flags` takes a function from the init line's `flags` field to
# it, and calls it once. Everything after the first line is identical, since
# the model travels on the wire from then on.
#
#   $ echo '{"op":"init","flags":"{\"name\":\"dawn\"}"}' | \
#       dawn run examples/projects/tea_dom_flags

use tea_core/app.{update, view}
use tea_dom/node.{Node}
use tea_dom/reactor.{serve_with_flags}
use greeting.{Model, Msg, init, encode, decode}

pub fn main() -> Unit !io = serve_with_flags(init, encode, decode, upd, vw)

# The two hooks, named rather than passed as lambdas: `serve_with_flags` needs
# a concrete message type to settle `M: Eq` against, and a lambda at the call
# site has no expectation to settle it from.
fn upd(m: Model, msg: Msg) -> Model = update(m, msg)

fn vw(m: Model) -> Node[Msg] = view(m)

examples/projects/tea_dom_flags/src/greeting.dawn

# The pure half: a model that cannot be written down without asking the page
# what it knew.
#
# `init` takes `Option[String]` rather than being a value, which is the whole
# point of the project. The string is opaque to `tea_dom` and is JSON here
# because that is what a page has to hand -- `JSON.stringify` on one side and
# `json/parser` on the other -- but nothing about the boundary says so, and an
# application that wants a bare locale name can read one.
#
# Everything here is total. Flags arrive from a page, so "no flags", "not
# JSON", "JSON that is not an object" and "an object with no `name`" are four
# things a running application meets and none of them is a reason to panic:
# each falls back to the same default, and the default is visible in the view.

use std/map
use json/value.{Json, JStr, JInt, JObj}
use json/render.{render}
use json/parser.{parse}
use tea_core/app.{App, update, view}
use tea_dom/node.{Node}
use tea_dom/dsl.{button, div, el, text}

## Who the page said it was for, and how many times the button was pressed.
##
## The second field is here so the project has a turn that is not the first
## one: flags are read once, and a session that only ever sends `init` could
## not show that later turns carry the flagged model along in the model text
## rather than asking for the flags again.
pub type Model = { name: String, hellos: Int }

## The one message.
pub type Msg =
  | Hello
derive Show

## The model a fresh page starts from, given what that page already knew.
##
## The default is a real answer rather than a placeholder: a page that sends
## no flags gets `world`, and `world` is what the view then shows. That is
## what makes "the flags were dropped" a visible fact instead of an empty
## field somebody has to notice.
pub fn init(flags: Option[String]) -> Model = Model { name: named(flags), hellos: 0 }

fn named(flags: Option[String]) -> String =
  match flags {
    None -> "world"
    Some(text) ->
      match parse(text) {
        Ok(JObj(entries)) -> name_in(entries, "world")
        _ -> "world"
      }
  }

fn name_in(entries: Map[String, Json], fallback: String) -> String =
  match map.get(entries, "name") {
    Some(JStr(s)) -> s
    _ -> fallback
  }

fn hellos_in(entries: Map[String, Json]) -> Int =
  match map.get(entries, "hellos") {
    Some(JInt(v)) -> v
    _ -> 0
  }

impl App[Model] {
  type Msg = Msg
  type View = Node[Msg]

  fn update(m: Model, msg: Msg) -> Model =
    match msg {
      Hello -> Model { name: m.name, hellos: m.hellos + 1 }
    }

  fn view(m: Model) -> Node[Msg] = {
    let greeting: Node[Msg] = el("p", class: "greeting", kids: [text("hello, " ++ m.name)])
    let tally: Node[Msg] = el("p", class: "tally", kids: [text(to_string(m.hellos))])
    div(class: "flags", kids: [greeting, tally, button("hello", Hello, class: "again")])
  }
}

## The model as one line of JSON, and back. The name rides along in it, so
## every turn after the first is answered without the flags being sent again.
pub fn encode(m: Model) -> String =
  render(
    JObj(entries: map.from([
      ("name", JStr(value: m.name)),
      ("hellos", JInt(value: m.hellos)),
    ])),
  )

## Total, because a host may hand back anything.
pub fn decode(s: String) -> Model =
  match parse(s) {
    Ok(JObj(entries)) ->
      Model { name: name_in(entries, "world"), hellos: hellos_in(entries) }
    _ -> init(None)
  }

test "flags decide the model, and every way of not having them is the default" {
  assert init(Some("{\"name\":\"dawn\"}")) == Model { name: "dawn", hellos: 0 }
  let none: Option[String] = None
  assert init(none) == Model { name: "world", hellos: 0 }
  assert init(Some("")) == init(none)
  assert init(Some("not json at all")) == init(none)
  assert init(Some("[1,2]")) == init(none)
  assert init(Some("{\"nom\":\"dawn\"}")) == init(none)
  assert init(Some("{\"name\":7}")) == init(none)
}

test "the name is in the model text, so later turns need no second flags line" {
  let m = update(init(Some("{\"name\":\"dawn\"}")), Hello)
  assert m == Model { name: "dawn", hellos: 1 }
  assert decode(encode(m)) == m
  assert decode("not json at all") == init(None)
}

test "the view says the name, which is what makes a dropped flag visible" {
  let want: Node[Msg] =
    div(class: "flags", kids: [
      el("p", class: "greeting", kids: [text("hello, dawn")]),
      el("p", class: "tally", kids: [text("0")]),
      button("hello", Hello, class: "again"),
    ])
  assert view(init(Some("{\"name\":\"dawn\"}"))) == want
  assert view(init(None)) != want
}