Skip to content

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.

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 a key; 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, in items, and the addresses of the windows before and after in earlier and later (null at either end):

    const messages = tables.messages.filter(m => m.chat == chat).tail(50)
  • region static is 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.

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, on read and write only.
  • "signed-in": anybody who has signed in.
  • "self": on people only, 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 to people is the visitor who added the row. Nothing a form sends can change it.
  • "fill": "now" on a datetime is 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.

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.