Build an app: live lists, rows that belong to people, and a chat
A site can be an app: pages that stay up to date while they are open, visitors who sign in with a name, and rows that belong to the people who wrote them. This guide is three worked patterns, each smaller than the next. The language itself is on the card; this is how its pieces fit.
The second and third patterns need visitors who sign in, which a new site has and an older site may not have yet. On an older site, ask the owner first; until visitors can sign in there, build the first pattern only.
1. A list that stays up to date
Section titled “1. A list that stays up to date”Give the part of a page that shows rows an id and the word region. When
a row it read is added, changed or removed, the region is drawn again and
the open page shows it, with no reload and no script of the site’s own.
<script server> const going = tables.guests.filter(g => g.coming == true).sortBy(g => g.name)</script>
<section id="guests" region note="Who is coming. Updates as people answer."> <h2>{plural(going.length, { one: "# guest so far", other: "# guests so far" })}</h2> <ul> <li :for={guest in going} key={guest.id}>{guest.name}</li> </ul></section>-
A region needs an
id. A loop inside it may give each item akey; without one, each row’s number is used, and either way one item changes without the others being drawn again. -
A region reads only what the page may read. A private table’s rows never reach a region.
-
A long list is read from its end.
tail(50)gives the newest fifty, oldest first, initems, and the addresses of the windows before and after inearlierandlater(null at either end):const messages = tables.messages.filter(m => m.chat == chat).tail(50) -
region staticis drawn when the page loads and when a form in it is sent, and not after a write. Use it for a form that should answer in place, or for a list of everybody, which would otherwise be drawn again on every open copy of the page each time anybody joins.
2. Rows that belong to people
Section titled “2. Rows that belong to people”A visitor joins a site by picking a name. From then on the site knows them
as visitor, their own row of the table people.
The table of people is tables/people.json. Its name is fixed, and so
is its one required field, name:
{ "name": "People", "access": { "read": "signed-in" }, "fields": [ { "name": "name", "type": "text", "label": "Name", "required": true, "filterable": true, "sortable": true } ]}Signing in is a form that says sign-in. Neither it nor signing out
has an action of its own:
<form sign-in class="flex flex-row gap-sm"> <input name="name" required maxlength="40" autocomplete="nickname" aria-label="Your name"> <button class="btn-primary">Join</button> <p :if={form.failed} class="text-accent">{form.message}</p></form>
<form sign-out forget><button>Leave for good</button></form>While a name is the only way in, signing out leaves a person nobody can be
again, their name still in every list. So sign out with forget, which
removes the person too, and say on the button that leaving is for good. The
checker warns of a bare <form sign-out> on such a site.
A page for signed-in visitors only says so in its server script, and everybody else is sent to sign in:
<script server> export const access = "signed-in"</script>Who may do what to a table’s rows is its access: read, write
(add a row), change and remove. Each is one of these, or a list of them:
"owner": no visitor. Every verb is this until it says otherwise."public": anybody, signed in or not, onreadandwriteonly."signed-in": anybody who has signed in."self": onpeopleonly, the person the row is.{ "field": "author" }: the person that field points at, or anybody in it when it is a list of people.{ "through": "chat" }: whoever may read the row that field points at, on every verb. A message is read, and written, by whoever may read its chat. A chain of them (a comment through its card, a card through its board) may go three steps.
"access": { "read": { "through": "chat" }, "write": { "through": "chat" }, "change": { "field": "author" }, "remove": { "field": "author" }}A row a visitor may not read is not found: its page answers 404, and a
list leaves it out. The table’s rule is applied to every read and every
write, so write no check of your own. Reading a table with a through rule
names the row it goes through, .filter(m => m.chat == chat), and the
checker refuses a read that does not.
What the platform fills in:
"fill": "visitor"on a reference topeopleis the visitor who added the row. Nothing a form sends can change it."fill": "now"on adatetimeis when the row was added."includes": "visitor"on a list of people (references) puts the visitor in the list of a row they add, so whoever starts a chat is in it.
A list of people a rule reads changes only as its own keys say. "add"
names who may put somebody in it besides the owner (the same rules as
access). "leave": true lets a person take themselves out. Nobody else
is added or taken out by a visitor’s form.
The owner approves what widens. A merge waits for the owner on the
Branches screen when it gives visitors more than they had (a rule that lets
more people at more rows, a list more people may join) or lets visitors
learn more. When merge_branch answers that it waits, pass its sentences
on to the owner.
3. A chat
Section titled “3. A chat”A chat is the first two patterns together. The language has no word for who is online or who is typing, and none that marks messages read when they are shown, so a chat here has none of them.
Three tables. people as above. chats: a title, its members (a list
of people with "includes": "visitor" and "max": 50), whether it is
between two people, how many messages it has had (sent), and its last
message. messages: its chat, its author ("fill": "visitor"), its
text and when it was sent ("fill": "now").
{ "name": "Chats", "access": { "read": { "field": "members" }, "write": "signed-in", "change": { "field": "members" } }, "fields": [ { "name": "title", "type": "text", "label": "Title" }, { "name": "members", "type": "references", "table": "people", "label": "Members", "required": true, "filterable": true, "includes": "visitor", "max": 50 }, { "name": "direct", "type": "boolean", "label": "Between two people", "filterable": true }, { "name": "sent", "type": "number", "label": "Messages sent" }, { "name": "last", "type": "reference", "table": "messages", "label": "Last message" } ]}One page per chat, pages/c/[slug].vzk, signed-in only. Its regions
are the messages and the box to write in:
<script server> export const access = "signed-in"
const chat = tables.chats.row(params.slug) const messages = tables.messages.filter(m => m.chat == chat).tail(50)
function send(input) { const message = tables.messages.insert({ chat, text: input.text }) tables.chats.update(chat, { sent: (chat.sent ?? 0) + 1, last: message }) }</script>
<ol id="messages" region> <li :for={message in messages.items} key={message.id}>{message.text}</li></ol>
<footer id="write" region static> <form id="send" action={send}> <textarea name="text" required maxlength="4000" aria-label="Message"></textarea> <button class="btn-primary">Send</button> </form></footer>- A chat nobody may read is a 404 for whoever is not in it, by the table’s own rule.
Prove it before asking to merge. Write scenarios for what the app
promises: two people sign in, one starts a chat, a message reaches the
other without a reload, changing and removing one’s own message, and a 404
for somebody signed in who is not in the chat. Run them with run_tests on
the branch. The owner’s merge shows that they pass.