Skip to content

Chapter 10 of 41

The board on screen

Show the same board in two places with Views: a Board tab on every space's page, plugged in through a resource kind, and a terminal View printed by a command. Both stay live.

  • Part 3, the board
  • Requires Chapter 9
  • Code lesson-3.2
On this page 8

The pain

The board's rules are right, but you can't see it. And you work in two places, the browser and the terminal: two front ends usually means two copies of every screen's logic, drifting apart.

The problem

What the board _is_ (its documents, its Actions) shouldn't be written twice. Only how it _looks_ should differ, per place, in each place's own terms: React in the browser, text in the terminal. And both should follow the data as it changes, without polling or refresh buttons.

The solution

Views, one per place, over the same collections and Actions.

On the web

boardsWeb (web.tsx) is boards' web front end:

TypeScript
export const boardsWeb: Plugin = definePlugin({
  name: "boards-web",
  requires: ["web-shell"],
  views: {
    board: boardPageView,
    "board.inline": boardInlineView,
    "boards.tab": boardsTabView,
    "boards.palette": boardsPaletteView,
  },
  resources: {
    spaceTabs: { board: { title: "Board", view: "boards.tab", icon: "check-circle", order: 20 } },
    palette: { boards: { view: "boards.palette", order: 30 } },
  },
});

One line, the spaceTabs entry, puts a Board tab on every space's page: the page you wrote in Chapter 8 shows it without knowing boards exist. /board/<id> is a board's own page (the View named board), and the palette finds boards by name.

The board itself is a View that reads cards and draws columns (views/web/board-view.tsx). Moving a card is a column picker and two buttons, each a call to the same Action every caller uses:

TSX
const move = async (card: string, place: { column?: string; before?: string; after?: string }) => {
  setBusy(true);
  setError(undefined);
  try {
    await call("card.move", { card, ...place });
  } catch (e) {
    setError(message(e));
  } finally {
    setBusy(false);
  }
};
// …
<Select
  size="sm"
  aria-label={`Column for ${card.title}`}
  value={card.column}
  disabled={busy}
  onChange={(event) => void move(card._id, { column: event.target.value })}
>
  {board.columns.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</Select>;

A refusal (a column that was just removed, say) comes back as an error with its message, shown where you clicked.

Nothing re-renders by hand. The View is observed: the runtime remembers what it read, and when a commit changes any document that matched those reads, it runs the View again. A card moved by an agent, in another tab, or from the terminal, shows moved here.

In the terminal

boardsTerminal (terminal.ts) adds a terminal View and a command:

TypeScript
export const boardsTerminal: Plugin = definePlugin({
  name: "boards-terminal",
  requires: ["boards", "terminal-shell"],
  views: { "terminal.board": boardView },
  resources: { commands },
});

A terminal View returns Ink elements or plain text. The board's returns text, with the terminal toolkit's bold and dim (views/terminal/board.ts):

views/terminal/board.ts
export const boardView = (item: { space: string; board?: string }, ctx: ViewContext): string => {
  const found = ctx.db.collection<Board>("board").find({ in: item.space }, { sort: { at: 1 } });
  if (found.status !== "ready") return dim("Loading boards…");
  // … which boards, their cards, who owns each
  return boards.map((board) =>
    [
      bold(board.name + (board.archived ? " (archived)" : "")),
      ...(board.description ? [dim(board.description)] : []),
      ...board.columns.flatMap((column) => {
        const here = cards.filter((card) => card.board === board._id && card.column === column.id);
        return [
          `  ${bold(column.name)} ${dim(`(${here.length})`)}`,
          ...(here.length ? here.map(cardLine) : [dim("    No cards.")]),
        ];
      }),
    ].join("\n")
  ).join("\n\n");
};

Plain text has a practical reason too: Node runs TypeScript files but not JSX. The browser's code is bundled (esbuild reads .tsx), the terminal's isn't, so its Views are .ts.

The /board command (resources/terminal-commands.ts) prints that View for the space you're working in. terminal.ts adds boardsTerminal; front.ts adds boardsWeb.

Run it

In the browser, make a space, open its Board tab, and make a board, "Launch": it comes with its columns. Add two cards in To do, "Record the trailer" for North and "Review the course page" for you, and move the first to Doing. Then, with the server stopped, the terminal on the same store:

ana › /spaces
[1] Course launch · shared · 5b4479ac-…
[2] Personal · personal · personal:ana ●
ana › /work 1
You work in Course launch now.
ana › /board
Launch
  Backlog (0)
    No cards.
  To do (1)
    • Review the course page · Ana
  Doing (1)
    • Record the trailer · North
  Blocked (0)
    No cards.
…

The same cards, in the same order.

Check it

tests/terminal.test.ts renders terminal.board with runtime.render and runs /board through the real terminal host with a fake standard output. tests/web.test.ts observes a board page, creates, edits and moves cards through the Actions, and checks each change shows up in the observed View.

What you have now

One board, two front ends, no logic in either beyond how it looks. There's a third place to show it: North's prompt.