tea_dom_todo_keyed

The todo list with its rows keyed by id, which is the demo dawn-lang.dawnop.com mounts; its io surface is one call, exactly as tea_dom_counter's.

examples/projects/tea_dom_todo_keyed/src/main.dawn

# The todo list with its rows keyed by id, which is the demo dawn-lang.dawnop.com
# mounts; its io surface is one call, exactly as tea_dom_counter's.
#
# The same binary answers a browser and a terminal, because the boundary is
# lines of JSON on stdin and stdout and nothing else. Built for wasm
# (`dawnc build --target wasm --reactor`) it is a reactor the page calls once
# per event; run from a shell (`dawn run`) it is a session you can type at,
# which is how the example-main contract holds it to a transcript without a
# browser or a wasm engine in sight.
#
#   $ echo '{"op":"init"}' | dawn run examples/projects/tea_dom_todo_keyed

use tea_core/app.{update, view}
use tea_dom/node.{Node}
use tea_dom/reactor.{serve}
use codec.{encode, decode}
use todo.{Model, Msg, init}

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

# The two hooks, named rather than passed as lambdas: `serve` 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_todo_keyed/src/codec.dawn

# The model as opaque text, and back.
#
# The counter could be one integer, so it used `to_string`. A record with a
# list in it wants a real encoder, and `packages/json` is one, so this file is
# what a second application on this bridge actually looks like: every turn
# renders the whole model and every turn parses it back, because the reactor
# holds nothing between calls.
#
# `decode` is total. A host may hand back anything -- a truncated string, a
# model from an older build, a field of the wrong type -- and the answer is a
# model rather than a panic, so a corrupted page recovers by clicking. Every
# reader here has a fallback and none of them can fail.
#
# The encoding is deliberately the plain one: field names in full, ids and
# titles per item, no delta and no compression. That is what the report's
# per-turn cost section measured, and a cleverer encoding would have measured
# the cleverness instead of the boundary.

use std/list
use std/map
use std/str
use json/value.{Json, JStr, JInt, JArr, JObj, JBool, JNull}
use json/render.{render}
use json/parser.{parse}
use todo.{Model, Todo, Filter, All, Active, Done, init}

## The model as one line of JSON.
pub fn encode(m: Model) -> String =
  render(
    JObj(entries: map.from([
      ("todos", JArr(items: list.map(m.todos, t => enc_todo(t)))),
      ("next", JInt(value: m.next_id)),
      ("filter", JStr(value: filter_name(m.filter))),
      ("draft", JStr(value: m.draft)),
      ("edit", JStr(value: m.edit)),
      ("editing", enc_editing(m.editing)),
    ])),
  )

fn enc_todo(t: Todo) -> Json =
  JObj(entries: map.from([
    ("id", JInt(value: t.id)),
    ("title", JStr(value: t.title)),
    ("done", JBool(value: t.done)),
  ]))

fn filter_name(f: Filter) -> String =
  match f {
    All -> "all"
    Active -> "active"
    Done -> "done"
  }

# Which row is open, or `null`. A JSON null rather than a sentinel integer,
# because "no row" is not a row number and encoding it as one would make -1 a
# value `decode` has to know about.
fn enc_editing(e: Option[Int]) -> Json =
  match e {
    Some(id) -> JInt(value: id)
    None -> JNull
  }

## Text back to a model, and never a failure.
pub fn decode(s: String) -> Model =
  match parse(s) {
    Err(_) -> init()
    Ok(JObj(entries)) ->
      Model {
        todos: dec_todos(map.get(entries, "todos")),
        next_id: dec_int(map.get(entries, "next"), 1),
        filter: dec_filter(dec_str(map.get(entries, "filter"), "all")),
        draft: dec_str(map.get(entries, "draft"), ""),
        edit: dec_str(map.get(entries, "edit"), ""),
        editing: dec_editing(map.get(entries, "editing")),
      }
    Ok(_) -> init()
  }

fn dec_str(j: Option[Json], fallback: String) -> String =
  match j {
    Some(JStr(v)) -> v
    _ -> fallback
  }

fn dec_int(j: Option[Json], fallback: Int) -> Int =
  match j {
    Some(JInt(v)) -> v
    _ -> fallback
  }

fn dec_bool(j: Option[Json], fallback: Bool) -> Bool =
  match j {
    Some(JBool(v)) -> v
    _ -> fallback
  }

fn dec_todos(j: Option[Json]) -> List[Todo] =
  match j {
    Some(JArr(items)) -> list.map(items, i => dec_todo(i))
    _ -> []
  }

fn dec_todo(j: Json) -> Todo =
  match j {
    JObj(entries) ->
      Todo {
        id: dec_int(map.get(entries, "id"), 0),
        title: dec_str(map.get(entries, "title"), ""),
        done: dec_bool(map.get(entries, "done"), false),
      }
    _ -> Todo { id: 0, title: "", done: false }
  }

fn dec_filter(s: String) -> Filter =
  if s == "active" {
    Active
  } else if s == "done" {
    Done
  } else {
    All
  }

fn dec_editing(j: Option[Json]) -> Option[Int] =
  match j {
    Some(JInt(v)) -> Some(v)
    _ -> None
  }

# ---- tests ---------------------------------------------------------------

fn sample() -> Model =
  Model {
    todos: [
      Todo { id: 1, title: "buy milk", done: true },
      Todo { id: 2, title: "ship tea", done: false },
    ],
    next_id: 3,
    filter: Active,
    draft: "hal",
    edit: "",
    editing: Some(2),
  }

test "a model round-trips through its own text" {
  assert decode(encode(sample())) == sample()
  assert decode(encode(init())) == init()
  let all_open: List[Option[Int]] = [None, Some(1), Some(7)]
  let open_check =
    list.all(all_open, e => decode(encode(Model { ..sample(), editing: e })).editing == e)
  assert open_check
  let all_filters: List[Filter] = [All, Active, Done]
  assert list.all(all_filters, f =>
      decode(encode(Model { ..sample(), filter: f })).filter == f)
}

test "the encoding is one line, and a quote in a title cannot end it" {
  let tricky = Model { ..init(), todos: [Todo { id: 1, title: "a\"b\nc", done: false }] }
  let line = encode(tricky)
  assert not str.contains(line, "\n")
  assert decode(line) == tricky
}

test "decode is total: anything at all reads as a model" {
  assert decode("") == init()
  assert decode("not json") == init()
  assert decode("[1,2,3]") == init()
  assert decode("{}") == Model { ..init(), next_id: 1 }
  # A field of the wrong type falls back rather than failing.
  assert decode("{\"todos\":7,\"next\":\"x\"}") == init()
  assert decode("{\"todos\":[{\"id\":\"x\"}]}").todos == [Todo { id: 0, title: "", done: false }]
}

examples/projects/tea_dom_todo_keyed/src/todo.dawn

# The pure half of the demo: model, messages, `update` and `view`. No io, no
# wire format, no host, so every assertion below is `==` on a value.
#
# The keyed twin of examples/projects/tea_dom_todo, and dawn.toml says why the
# two are separate projects. One line differs: the `<ul>` is built with
# `dsl.keyed` and each row is named by the id it already carries, so the
# reconciler pairs rows by identity instead of by position.
#
# What that buys is not on the wire and not in a patch count. It is that the
# element a browser is holding state inside -- a caret, a selection, an IME
# composition, the focus itself -- is still the same element after a row above
# it is deleted. `move` and `insert` carry a node the document already has;
# index pairing would rewrite it in place, and rewritten is indistinguishable
# from correct in every transcript and wrong under a user's hands.
#
# Why a todo list and not a bigger counter. Three things the counter cannot
# show up in a browser at all:
#
#   a child list whose *contents* vary   the counter's bar varies only in
#                                        length, so index pairing is always
#                                        right for it. Rows carry identity,
#                                        and deleting one in the middle is
#                                        where pairing has to know it.
#   a draft the user is typing           text that belongs to one widget and
#                                        to no persisted record. In this
#                                        architecture it is a model field.
#   a mode                               a row that is being edited is a
#                                        different shape from a row that is
#                                        not, decided by a field of the model
#                                        rather than by the row.
#
# Two real `<input>`s, and what they cost. A listener declares that it wants
# the element's value and names the function that reads it
# (`on_value("input", SetDraft)`), and the host brings that one string back.
# So a title is one message however long it is, and pasting, an IME and a real
# keyboard all work. What it does not buy is *fewer* fields: `draft` and `edit` are still
# model fields, because the two boxes are controlled -- the view renders them,
# so a patch can correct them and the document stays a function of the model.
# Making them uncontrolled is what would cut the turns rather than the
# messages, and it is a separate question.

use std/list
use std/str
use tea_core/app.{App, update, view}
use tea_core/diff.{Patch, RemoveKid, diff}
use tea_core/tree.{key}
use tea_dom/node.{Node, Text, Elem, On, deliver}
use tea_dom/dsl.{button, div, el, input, keyed, on_click, on_value, text}

## One item. `id` is the identity the user means, and here it is also the key
## the reconciler pairs by -- `view` spells it into the keyed `<ul>` below.
pub type Todo = { id: Int, title: String, done: Bool }

## Which items the list shows.
pub type Filter =
  | All
  | Active
  | Done
derive Show

## The whole model.
##
## `draft`, `edit` and `editing` are the three fields that exist because this
## architecture has no local state: each is a piece of one widget's private
## business lifted to the top of the application. `todos`, `next_id` and
## `filter` are the parts a user would call the data.
##
## `editing` is which row is open, and `None` is a real state rather than a
## missing one. It is not "where the keyboard is": that is the browser's to
## know now, which is one thing an event payload took off the model.
pub type Model = {
  todos: List[Todo],
  next_id: Int,
  filter: Filter,
  draft: String,
  edit: String,
  editing: Option[Int],
}

## What the buttons and the two fields mean.
##
## `SetDraft` and `SetEdit` are the two that carry what the user typed. The
## view names the constructor itself as the listener's function, so the host
## brings back what the field holds and the constructor is what it lands in.
## Nothing outside the view decides that, which is why the host still cannot
## name a message.
pub type Msg =
  | SetDraft(text: String)
  | SetEdit(text: String)
  | Add
  | Toggle(id: Int)
  | Drop(id: Int)
  | Only(f: Filter)
  | Edit(id: Int)
  | Save
  | Cancel
  | Boom
derive Show

## The model a fresh page starts from.
pub fn init() -> Model =
  Model { todos: [], next_id: 1, filter: All, draft: "", edit: "", editing: None }

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

  fn update(m: Model, msg: Msg) -> Model =
    match msg {
      SetDraft(t) -> Model { ..m, draft: t }
      SetEdit(t) -> Model { ..m, edit: t }
      Add ->
        if str.trim(m.draft) == "" {
          m
        } else {
          Model {
            ..m,
            todos: m.todos ++ [Todo { id: m.next_id, title: str.trim(m.draft), done: false }],
            next_id: m.next_id + 1,
            draft: "",
          }
        }
      Toggle(id) ->
        Model {
          ..m,
          todos: list.map(m.todos, t => if t.id == id { Todo { ..t, done: not t.done } } else { t }),
        }
      Drop(id) ->
        Model {
          ..m,
          todos: list.filter(m.todos, t => t.id != id),
          editing: if m.editing == Some(id) { None } else { m.editing },
          edit: if m.editing == Some(id) { "" } else { m.edit },
        }
      Only(f) -> Model { ..m, filter: f }
      Edit(id) -> Model { ..m, editing: Some(id), edit: title_of(m.todos, id) }
      Save ->
        match m.editing {
          Some(id) ->
            if str.trim(m.edit) == "" {
              Model { ..m, editing: None, edit: "" }
            } else {
              Model {
                ..m,
                todos: retitle(m.todos, id, str.trim(m.edit)),
                editing: None,
                edit: "",
              }
            }
          None -> m
        }
      Cancel -> Model { ..m, editing: None, edit: "" }
      # Deliberate, and the only unhandled failure in this tree: `tea_dom`'s
      # `serve` catches it at the boundary, the host keeps its model, and the
      # next message is answered normally. The counter carries the same
      # button for the same reason.
      Boom -> panic("boom: update failed on purpose with " ++ to_string(len(m.todos)) ++ " todos")
    }

  fn view(m: Model) -> Node[Msg] = {
    let heading: Node[Msg] = el("h1", kids: [text("dawn todo")])
    let composer: Node[Msg] = compose_row(m)
    let filters: Node[Msg] = filter_row(m.filter)
    # Keyed, which is the one line this application does not share with
    # tea_dom_todo. A row's key is the id the user means, so deleting row 1
    # while row 3 is open moves the open row rather than rewriting it, and the
    # `<input>` the caret is in is the same element afterwards.
    let items: Node[Msg] =
      keyed("ul", class: "list", kids: list.map(visible(m), t => (to_string(t.id), row(m, t))))
    let status: Node[Msg] = el("p", class: "status", kids: [text(summary(m))])
    div(class: root_class(m), kids: [heading, composer, filters, items, status])
  }
}

# ---- update helpers ------------------------------------------------------

fn title_of(ts: List[Todo], id: Int) -> String =
  match list.find(ts, t => t.id == id) {
    Some(t) -> t.title
    None -> ""
  }

fn retitle(ts: List[Todo], id: Int, title: String) -> List[Todo] =
  list.map(ts, t => if t.id == id { Todo { ..t, title: title } } else { t })

## The items the current filter admits, in insertion order.
pub fn visible(m: Model) -> List[Todo] =
  match m.filter {
    All -> m.todos
    Active -> list.filter(m.todos, t => not t.done)
    Done -> list.filter(m.todos, t => t.done)
  }

# ---- view parts ----------------------------------------------------------

# The root's class is the only thing an edit changes outside the row being
# edited, which makes "started editing" one `set-self` at [] in the patch
# stream rather than something a reader has to infer.
fn root_class(m: Model) -> String =
  match m.editing {
    Some(_) -> "todo editing"
    None -> "todo"
  }

# The composer is a controlled field: the `value` prop renders `m.draft`, and
# the listener asks for the element's value back. A message per `input` event
# is a message per *edit* rather than per character -- a paste of forty
# characters is one -- and the two halves are what keep the model and the
# document agreeing on what is in the box.
fn compose_row(m: Model) -> Node[Msg] = {
  let field: Node[Msg] = input(class: "field", value: m.draft, on: [on_value("input", SetDraft)])
  let add: Node[Msg] = button("add", Add, class: "add")
  let boom: Node[Msg] = button("boom", Boom, class: "boom")
  div(class: "compose", kids: [field, add, boom])
}

fn filter_row(cur: Filter) -> Node[Msg] =
  div(class: "filters", kids: [
    filter_button("all", All, cur),
    filter_button("active", Active, cur),
    filter_button("done", Done, cur),
  ])

fn filter_button(label: String, which: Filter, cur: Filter) -> Node[Msg] =
  button(label, Only(f: which), class: if which == cur { "filter on" } else { "filter" })

# Two shapes, decided by a field of the model rather than by the row: an
# `<li>` being edited has a field and two verbs, an `<li>` at rest has a
# checkbox, a title and a delete. They agree on tag and on child count and on
# nothing else, which is what makes an edited row in the tail of a deletion
# an outright `replace` rather than a text patch.
fn row(m: Model, t: Todo) -> Node[Msg] =
  if m.editing == Some(t.id) {
    el("li", class: "row editing", kids: [
      input(class: "draft", value: m.edit, on: [on_value("input", SetEdit)]),
      button("save", Save, class: "save"),
      button("cancel", Cancel, class: "cancel"),
    ])
  } else {
    el("li", class: if t.done { "row done" } else { "row" }, kids: [
      button(if t.done { "[x]" } else { "[ ]" }, Toggle(id: t.id), class: "box"),
      el("span", class: "title", on: [on_click(Edit(id: t.id))], kids: [text(t.title)]),
      button("x", Drop(id: t.id), class: "kill"),
    ])
  }

fn summary(m: Model) -> String = {
  let total = len(m.todos)
  let done = len(list.filter(m.todos, t => t.done))
  "$done of $total done"
}

# ---- tests ---------------------------------------------------------------

fn seeded(n: Int) -> Model = seed_go(init(), 1, n)

fn seed_go(m: Model, i: Int, n: Int) -> Model =
  if i > n {
    m
  } else {
    seed_go(update(update(m, SetDraft(text: "task" ++ to_string(i))), Add), i + 1, n)
  }

test "a payload lands in the field the listener that heard it names" {
  # Where the two `<input>`s send what was typed. `==` on the tree cannot see
  # this any more -- a listener's identity is its event name and its payload
  # kind -- so it is asked of the listener directly, with `node.deliver`.
  #
  # The composer's field and the row editor's field are two listeners that are
  # equal as far as the reconciler and the host are concerned, and mean two
  # different things. That is the whole of what has to be right here.
  assert deliver(on_value("input", SetDraft), "buy milk") == SetDraft(text: "buy milk")
  assert deliver(on_value("input", SetEdit), "new") == SetEdit(text: "new")
  # The two the wire cannot tell apart.
  assert on_value("input", SetDraft) == on_value("input", SetEdit)
  # A button's listener ignores what it is handed, which is what lets the
  # boundary pass `""` to every `NoData` listener.
  assert deliver(on_click(Add), "buy milk") == Add
  assert deliver(on_click(Toggle(id: 3)), "buy milk") == Toggle(id: 3)
  # And the field it lands in is the field `update` then writes.
  let composing = update(init(), deliver(on_value("input", SetDraft), "buy milk"))
  assert composing.draft == "buy milk"
  assert composing.edit == ""
}

test "each of the two fields in the view is wired to its own message" {
  # The assertion the tree comparison below cannot make, at the two places it
  # matters. `first_kid` reaches the `<input>` of the composer and of an open
  # row; `listener_of` reads the one listener each declares.
  match listener_of(first_kid(compose_row(init()))) {
    None -> { assert false }
    Some(l) -> { assert deliver(l, "buy milk") == SetDraft(text: "buy milk") }
  }
  let open = update(seeded(2), Edit(id: 2))
  match listener_of(first_kid(row(open, Todo { id: 2, title: "task2", done: false }))) {
    None -> { assert false }
    Some(l) -> { assert deliver(l, "task2!") == SetEdit(text: "task2!") }
  }
}

fn listener_of(n: Option[Node[Msg]]) -> Option[On[Msg]] =
  match n {
    None -> None
    Some(w) ->
      match w {
        Text(_) -> None
        Elem(_, _, on, ..) -> if list.is_empty(on) { None } else { Some(on[0]) }
      }
  }

test "typing is one message whatever the length of what was typed" {
  # The thing the key palette could not do. A title of any length, a paste, an
  # IME commit: one `input` event, one message, one turn.
  let m = update(init(), SetDraft(text: "buy milk and eggs"))
  assert m.draft == "buy milk and eggs"
  # A field the user emptied is an empty draft rather than an unchanged one:
  # the payload is the field's value, not a keystroke to append.
  assert update(m, SetDraft(text: "")).draft == ""
}

test "add commits the draft and clears it" {
  let m = update(update(init(), SetDraft(text: "buy milk")), Add)
  assert m.todos == [Todo { id: 1, title: "buy milk", done: false }]
  assert m.draft == ""
  assert m.next_id == 2
  # An empty draft is not a todo.
  assert update(m, Add) == m
}

test "toggle, drop and filter are the plain list operations" {
  let m = seeded(3)
  assert len(m.todos) == 3
  let t = update(m, Toggle(id: 2))
  assert t.todos[1].done
  assert len(visible(Model { ..t, filter: Active })) == 2
  assert len(visible(Model { ..t, filter: Done })) == 1
  let d = update(t, Drop(id: 2))
  assert len(d.todos) == 2
  assert d.todos[1].id == 3
}

test "an edit is a second field, and the two never feed each other" {
  let m = update(seeded(3), Edit(id: 2))
  assert m.editing == Some(2)
  assert m.edit == "task2"
  let typing = update(m, SetEdit(text: "task2!"))
  assert typing.edit == "task2!"
  # The composer's draft is untouched while a row is open, which is the
  # assertion the routing mutant in scripts/wasm-dom-contract breaks.
  assert typing.draft == ""
  let saved = update(typing, Save)
  assert saved.todos[1].title == "task2!"
  assert saved.editing == None
  assert saved.edit == ""
  # Cancel keeps the old title.
  assert update(typing, Cancel).todos[1].title == "task2"
}

test "dropping the row being edited closes the editor" {
  let m = update(seeded(3), Edit(id: 2))
  let d = update(m, Drop(id: 2))
  assert d.editing == None
  assert d.edit == ""
}

test "the view is a value, so a frame is one assertion" {
  let m = view(init())
  match m {
    Text(_) -> { assert false }
    Elem(tag, props, on, kids, ..) -> {
      assert tag == "div"
      assert props == [("class", "todo")]
      assert list.is_empty(on)
      # heading, composer, filters, list, status
      assert len(kids) == 5
    }
  }
  match view(update(seeded(1), Edit(id: 1))) {
    Text(_) -> { assert false }
    Elem(_, props, ..) -> { assert props == [("class", "todo editing")] }
  }
}

test "both fields are controlled, so the document renders the model" {
  # A `value` prop carrying the model's text, on each of the two. Without it
  # the field would be the browser's and nothing could clear it -- `Add`
  # clears the draft, and this is what makes that reach the screen.
  let field: Node[Msg] = input(class: "field", value: "buy milk", on: [on_value("input", SetDraft)])
  assert first_kid(compose_row(update(init(), SetDraft(text: "buy milk")))) == Some(field)
  let editor: Node[Msg] = input(class: "draft", value: "task2", on: [on_value("input", SetEdit)])
  let open = update(seeded(2), Edit(id: 2))
  assert first_kid(row(open, Todo { id: 2, title: "task2", done: false })) == Some(editor)
}

## The whole reason this project is a second copy. Every other assertion in
## this file passes on the unkeyed original too.
test "each row is named by the id the user means" {
  match list_of(view(seeded(3))) {
    None -> { assert false }
    Some(ul) ->
      match ul {
        Text(_) -> { assert false }
        Elem(tag, _, _, rows, ..) -> {
          assert tag == "ul"
          assert map(rows, key) == [Some("1"), Some("2"), Some("3")]
        }
      }
  }
}

## And what the keys are for. Index pairing would compare the open editor --
## last of three before, second of two after -- against a resting row, and
## answer `Replace`: a fresh `<input>`, and the caret that was in the old one
## is gone with it. Keyed, the removal is the only structural op and the row
## being edited is not mentioned at all, so the element survives.
##
## `Replace` is what a browser cannot hide and a transcript cannot see: the
## document after it is right either way.
test "deleting a row above the open one leaves the open one alone" {
  let open = update(seeded(3), Edit(id: 3))
  let after = update(open, Drop(id: 1))
  let ps = diff(view(open), view(after))
  # the list's own edit, and nothing under it
  assert filter(ps, p => p.path == [3]) == [Patch { path: [3], op: RemoveKid(at: 0) }]
  assert filter(ps, p => len(p.path) > 1 && p.path[0] == 3) == []
  # the editor is still open, so the root is untouched and the count line is
  # the only other thing that moved
  assert filter(ps, p => p.path == []) == []
  assert len(ps) == 2
}

fn list_of(n: Node[Msg]) -> Option[Node[Msg]] =
  match n {
    Text(_) -> None
    Elem(_, _, _, ks, ..) -> get(ks, 3)
  }

fn first_kid(n: Node[Msg]) -> Option[Node[Msg]] =
  match n {
    Text(_) -> None
    Elem(_, _, _, ks, ..) -> if list.is_empty(ks) { None } else { Some(ks[0]) }
  }