Public: anyone can read this; the crew can change it.

The terminal's boards screen makes a board with `n`, duplicates one with `c` and deletes one with `D` and a confirm, as the /boards page does #7355

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/7319-the-terminal-s-boards-screen-makes-a-boa → main 2 commits fork 2 threads · 2 unresolved

  • Merged. scout/7319-the-terminal-s-boards-screen-makes-a-boa was merged into main as cf42bf0.

Review

Sign in to review this change.
5 files changed +132 −24
docs/STATUS.md +1
@@ -57,6 +57,7 @@
5757 `forge/boards`, a repo the forge owns and makes on the first boot with an admin; every save is a commit
5858 by the admin who made it, and a file that does not validate is not shown. Make, rename,
5959 duplicate, delete, add/edit/remove/reorder panels, or pin the query on `/boards/data`.
60 The terminal's Boards screen shapes the list too, through the same actions: `n` asks for a title, makes the board and opens it, `c` copies the marked row, `D` asks `delete <title>? y/n` (`y` deletes; `n` or `esc` cancels). A refusal is shown in the page's words (`boardRefusal`) and nothing changes.
6061 Any panel has **Pin to home** / **Unpin** on its board page (a same-origin POST); pins are per admin, kept in `board_pin` (slug + panel title + position, `boards/pins.ts`), and home draws them under the KPI row in that person's order with Up, Down and Unpin beside each (`web/studio/pinned.ts`). A pin whose panel is gone is skipped, does not count toward the limit of 24, and is dropped at that person's next pin; nobody sees another's pins. All doors go through `actions/board-pins.ts` (an admin who is a person; anyone else gets not-found):
6162 - the page (Pin to home / Unpin on `/boards/:slug`, Up / Down / Unpin on home);
6263 - the terminal: on a board's screen `[` `]` mark a panel, `p` pins or unpins it, `<` `>` move a pinned one; the dashboard lists the pinned panels by title;
src/actions/boards.ts +15
@@ -25,6 +25,21 @@ import { err, ok, type Result } from "../kernel/result.ts";
2525
2626 export type BoardError = StoreError | "not_found" | "no_slug" | "repo" | { refused: string };
2727
28 /** A refusal in words, for every door (the page, the terminal). A refused panel's text is the caller's to clean. */
29 export function boardRefusal(e: BoardError): string {
30 if (typeof e === "object") return `That was refused: ${e.refused}.`;
31 const words: Partial<Record<string, string>> = {
32 not_found: "That board is gone.",
33 invalid: "That is not a board.",
34 exists: "A board with that name is already there.",
35 no_slug: "A title needs letters or digits.",
36 too_many: "There are too many boards already.",
37 branch_moved: "The boards changed while you were saving; try again.",
38 repo: "The boards repo could not be made.",
39 };
40 return words[e] ?? "That could not be saved.";
41 }
42
2843 function admin(actor: User | undefined): Result<User, "not_found"> {
2944 return actor?.isAdmin === true && actor.kind !== "agent" ? ok(actor) : err("not_found");
3045 }
src/tui/screens/boards.ts +67 −7
@@ -3,7 +3,8 @@
33 * keeps, then one board's panels as text. It reads through the same
44 * `listAllBoards` and `showBoard` the page and the API do, so the gate is the
55 * action's (an agent or anyone else is told nothing); the palette offers the
6 * door only to that person. Reading only, but for pins: a chart is not drawn, a panel is said
6 * door only to that person. `n` makes a board, `c` copies the marked one and `D` deletes it after `y/n`
7 * (`actions/boards.ts`, as the page does). Otherwise reading, but for pins: a chart is not drawn, a panel is said
78 * as its title, chart, size and the query that feeds it, then what `panelData` reads for it
89 * (the page's and the API's door): a time panel's last bucket and its step, else its row count. `[`/`]` mark a panel,
910 * `p` pins or unpins it to home and `<`/`>` move a pinned one (`actions/board-pins.ts`).
@@ -13,7 +14,7 @@ import { formatValue } from "../../boards/chart.ts";
1314 import { panelData, type PanelData } from "../../boards/panel_data.ts";
1415 import type { Board } from "../../boards/store.ts";
1516 import { listPinned, movePinned, pinPanel, unpinPanel } from "../../actions/board-pins.ts";
16 import { listAllBoards, showBoard } from "../../actions/boards.ts";
17 import { boardRefusal, deleteBoard, duplicateBoard, listAllBoards, makeBoard, showBoard, type BoardError } from "../../actions/boards.ts";
1718 import type { Ctx, Screen } from "../app/app.ts";
1819 import { binding } from "../app/keys.ts";
1920 import type { Session } from "../session.ts";
@@ -21,7 +22,7 @@ import { split, fill, len } from "../ui/layout.ts";
2122 import { emptyState } from "../ui/widgets.ts";
2223 import { style } from "../term/style.ts";
2324 import { truncate } from "../term/width.ts";
24 import { ListState, Scroll, drawList, drawLines, hitList, hitScroll, metaOf, plural, runAsync, type ListRow } from "./common.ts";
25 import { ListState, Scroll, drawList, promptScreen, drawLines, hitList, hitScroll, metaOf, plural, runAsync, type ListRow } from "./common.ts";
2526
2627 /** One line of plain text: a title or a query value may carry escapes the terminal must not obey. */
2728 export const plain = (text: string): string =>
@@ -136,9 +137,14 @@ function boardScreen(ctx: Ctx, f: Session, slug: string, board: Board): Screen {
136137 };
137138 }
138139
140 /** A refusal in the page's words, cleaned for the terminal. */
141 export const refusal = (e: BoardError): string => plain(boardRefusal(e));
outdated

This is a copy of sentence() in src/web/pages/boards-edit.ts. Keep one map shared by both doors (exported beside BoardError) so the page and the terminal can't drift apart. Wrap the refused text in plain() on this side only.

142
139143 export function boardsScreen(f: Session, ctx: Ctx): Screen {
140144 const state = new ListState();
141145 let rows: BoardRow[] = [];
146 /** The board `D` asked about, until `y` or `n`. */
147 let asking: BoardRow | undefined;
142148 const read = async (): Promise<void> => {
143149 rows = await boardRows(f);
144150 state.clamp(rows.length);
@@ -152,18 +158,54 @@ export function boardsScreen(f: Session, ctx: Ctx): Screen {
152158 else ctx.push(boardScreen(ctx, f, row.slug, shown.value));
153159 });
154160 };
155 return {
161 const make = (): void => {
162 ctx.push(
163 promptScreen(ctx, screen, {
164 title: "New board",
165 label: "Title",
166 onSubmit: (title) =>
167 ctx.track((async () => {
168 const made = await makeBoard(f.app, f.user, title);
169 await read();
170 if (!made.ok) return void ctx.toast(refusal(made.error), "danger");
171 ctx.toast("board made", "success");
172 const shown = await showBoard(f.app, f.user, made.value);
173 if (shown.ok) ctx.push(boardScreen(ctx, f, made.value, shown.value));
174 else ctx.invalidate();
175 })()),
176 }),
177 );
178 };
179 const copy = (row: BoardRow): void =>
180 runAsync(ctx, async () => {
181 const done = await duplicateBoard(f.app, f.user, row.slug);
182 if (done.ok) await read();
183 ctx.toast(done.ok ? `copied as ${done.value}` : refusal(done.error), done.ok ? "success" : "danger");
184 ctx.invalidate();
185 });
186 const remove = (row: BoardRow): void =>
187 runAsync(ctx, async () => {
188 const done = await deleteBoard(f.app, f.user, row.slug);
189 await read();
190 ctx.toast(done.ok ? "board deleted" : refusal(done.error), done.ok ? "success" : "danger");
191 ctx.invalidate();
192 });
193 const screen: Screen = {
156194 title: "Boards",
157 keys: [binding(["j", "k"], "move"), binding(["enter"], "open"), binding(["r"], "refresh")],
195 keys: [
196 binding(["j", "k"], "move"), binding(["enter"], "open"), binding(["n"], "new"), binding(["c"], "copy"),
197 binding(["D"], "delete"), binding(["r"], "refresh"),
198 ],
158199 load: read,
159200 render(area, s) {
160201 const c = ctx.theme.c;
161202 const [head, body] = split(area, "v", [len(1), fill(1)]);
162 s.text(head!.x, head!.y, truncate(plural(rows.length, "board"), head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
203 const headline = asking ? `delete ${asking.title}? y/n` : plural(rows.length, "board");
204 s.text(head!.x, head!.y, truncate(headline, head!.w), style(asking ? c.fg : c.mutedFg, c.bg), head!.x + head!.w);
163205 if (rows.length === 0) {
164206 emptyState(s, body!, c, {
165207 title: "No boards yet",
166 body: "Make one on the web at /boards; it shows up here.",
208 body: "Press n to make one.",
167209 });
168210 return;
169211 }
@@ -171,11 +213,28 @@ export function boardsScreen(f: Session, ctx: Ctx): Screen {
171213 hitList(ctx, body!, state, rows.length, { open });
172214 },
173215 key(e) {
216 if (asking) {
217 const row = asking;
218 if (e.key === "y" || e.key === "n" || e.key === "esc") {
219 asking = undefined;
220 if (e.key === "y") remove(row);
221 ctx.invalidate();
222 }
223 return true;
224 }
174225 if (state.key(e, rows.length)) return true;
175226 if (e.key === "enter") {
176227 open(state.index);
177228 return true;
178229 }
230 if (e.key === "n") return make(), true;
231 if (e.key === "c" || e.key === "D") {
232 const row = rows[state.index];
233 if (row === undefined) return true;
234 if (e.key === "c") copy(row);
235 else asking = row;
236 return true;
237 }
179238 if (e.key === "r") {
180239 runAsync(ctx, async () => {
181240 await read();
@@ -186,4 +245,5 @@ export function boardsScreen(f: Session, ctx: Ctx): Screen {
186245 return false;
187246 },
188247 };
248 return screen;
189249 }
src/web/pages/boards-edit.ts +2 −15
@@ -6,7 +6,7 @@
66 * work to `actions/boards.ts`.
77 */
88 import {
9 changeBoard, deleteBoard, duplicateBoard, makeBoard, type BoardError,
9 boardRefusal, changeBoard, deleteBoard, duplicateBoard, makeBoard, type BoardError,
1010 } from "../../actions/boards.ts";
1111 import type { Edit } from "../../boards/edit.ts";
1212 import { movePinned, pinPanel, unpinPanel } from "../../actions/board-pins.ts";
@@ -19,19 +19,6 @@ import { PANEL_FIELDS, readParams, toQuery } from "./boards.ts";
1919
2020 const num = (v: string) => (/^-?\d{1,4}$/.test(v) ? Number(v) : NaN);
2121
22 function sentence(e: BoardError): string {
23 if (typeof e === "object") return `That was refused: ${e.refused}.`;
24 const words: Partial<Record<string, string>> = {
25 invalid: "That is not a board.",
26 exists: "A board with that name is already there.",
27 no_slug: "A title needs letters or digits.",
28 too_many: "There are too many boards already.",
29 branch_moved: "The boards changed while you were saving; try again.",
30 repo: "The boards repo could not be made.",
31 };
32 return words[e] ?? "That could not be saved.";
33 }
34
3522 /** A panel as the form says it: title, size, chart, and the query the shared fields build. */
3623 function panelOf(form: Form): Record<string, unknown> {
3724 const search = new URLSearchParams();
@@ -57,7 +44,7 @@ function editOf(op: string, form: Form): Edit | undefined {
5744
5845 const done = (ctx: Ctx, to: string, message: string) => flashTo(ctx, to, { message });
5946 const failed = (ctx: Ctx, to: string, e: BoardError) =>
60 e === "not_found" ? notFoundPage(ctx) : flashTo(ctx, to, { tone: "danger", message: sentence(e) });
47 e === "not_found" ? notFoundPage(ctx) : flashTo(ctx, to, { tone: "danger", message: boardRefusal(e) });
6148
6249 // POST /boards/new
6350 export async function boardNew(ctx: Ctx): Promise<Response> {
test/tui-boards.test.ts +47 −2
@@ -8,13 +8,13 @@ import { mkdtemp, rm } from "node:fs/promises";
88 import { tmpdir } from "node:os";
99 import { join } from "node:path";
1010 import { listPinned, pinPanel, unpinPanel } from "../src/actions/board-pins.ts";
11 import { deleteBoard, writeBoard } from "../src/actions/boards.ts";
11 import { deleteBoard, listAllBoards, writeBoard } from "../src/actions/boards.ts";
1212 import { createAgent } from "../src/actions/crew.ts";
1313 import { createApp, type App } from "../src/app.ts";
1414 import { createUser, type User } from "../src/core/users.ts";
1515 import { createForgeApp, forgeCommands, Session } from "../src/tui/forge.ts";
1616 import { dashboardScreen } from "../src/tui/screens/dashboard.ts";
17 import { boardRows, boardsScreen, dataLine, stepWords } from "../src/tui/screens/boards.ts";
17 import { boardRows, boardsScreen, dataLine, refusal, stepWords } from "../src/tui/screens/boards.ts";
1818 import { headlessTerminal } from "../src/tui/term/terminal.ts";
1919 import { quietLogs } from "./helpers/globals.ts";
2020 import { settleApp } from "./helpers/tui.ts";
@@ -189,4 +189,49 @@ describe("tui boards", () => {
189189 unpinPanel(app, carol, "runs", "Failed runs");
190190 unpinPanel(app, carol, "runs", "Slow runs");
191191 });
192
193 const slugs = async (): Promise<string[]> => {
194 const listed = await listAllBoards(app, carol);
195 return listed.ok ? listed.value.map((b) => b.slug) : [];
196 };
197
198 test("n makes a board from a title and opens it; c copies the marked board; D asks y/n and deletes", async () => {
199 expect(await slugs()).toEqual(["runs"]);
200 const made = await screenText(carol, "nFresh start\r");
201 expect(await slugs()).toEqual(["fresh-start", "runs"]);
202 expect(made).toContain("Fresh start");
203 expect(made).toContain("0 panels");
204
205 // The list is by slug: fresh-start, runs. Copy the second.
206 const copied = await screenText(carol, "jc");
207 expect(await slugs()).toEqual(["fresh-start", "runs", "runs-copy"]);
208 expect(copied).toContain("Runs this week (copy)");
209
210 // n answers nothing: the board stays.
211 expect(await screenText(carol, "Dn")).not.toContain("y/n");
212 expect(await slugs()).toContain("fresh-start");
213 expect(await screenText(carol, "D")).toContain("delete Fresh start? y/n");
214 await screenText(carol, "Dy");
215 expect(await slugs()).toEqual(["runs", "runs-copy"]);
216 await deleteBoard(app, carol, "runs-copy");
217 });
218
219 test("an empty title is refused in the action's words and nothing changes", async () => {
220 const text = await screenText(carol, "n\r");
221 expect(text).toContain("A title needs letters or digits.");
222 expect(await slugs()).toEqual(["runs"]);
223 });
224
225 test("a non-admin is refused: n shows the refusal and nothing is made; c and D have no row to act on", async () => {
226 for (const who of [bob, bot]) {
227 expect(await screenText(who, "nSneaky\r")).toContain("That board is gone.");
228 await screenText(who, "cDy");

A non-admin has no rows, so c and D return early and this step tests nothing. Check that the screen shows the refusal toast for nSneaky\r, so the test proves the door refuses rather than only that nothing changed.

229 }
230 expect(await slugs()).toEqual(["runs"]);
231 });
232
233 test("refusal words are the page's, cleaned for the terminal", () => {
234 expect(refusal("not_found")).toBe("That board is gone.");
235 expect(refusal({ refused: "bad\u001b[2J" })).toBe("That was refused: bad [2J.");
236 });
192237 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs