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