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

The terminal's board list renames the selected board with `e` and sets its range with `g`, as the board page's Rename and Settings do #8153

Merged opened this

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

Merged

mason/forge:mason/8033-the-terminal-s-board-list-renames-the-se → main 2 commits fork

  • Merged. mason/8033-the-terminal-s-board-list-renames-the-se was merged into main as bc50406.

Review

Sign in to review this change.
3 files changed +199 −7
docs/STATUS.md +1 −1
@@ -60,7 +60,7 @@
6060 `forge/boards`, a repo the forge owns and makes on the first boot with an admin; every save is a commit
6161 by the admin who made it, and a file that does not validate is not shown. Make, rename,
6262 duplicate, delete, add/edit/remove/reorder panels, or pin the query on `/boards/data`.
63 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). On a board's screen `x` asks `remove <panel title>? y/n` for the marked panel: `y` reads the board again and removes it through `changeBoard`, as the page's Remove does, unless another door moved or removed panels meanwhile (then nothing is removed and the screen shows the board as it is), `n` or `esc` cancels, and the key is offered only to a forge admin who is a person; a pin on the removed panel is left as the page leaves it. A refusal is shown in the page's words (`boardRefusal`) and nothing changes.
63 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). `e` renames the marked board and `g` sets its range, each in a prompt prefilled with the current value, through `changeBoard` as the page's Rename and Settings do: a range change keeps the board's refresh, a refusal is shown in the page's words and the board stays as it was, and both keys are offered only to a forge admin who is a person (for anyone else `g` keeps the list's jump to the first row; for an admin `home` does that, since `g` sets the range). On a board's screen `x` asks `remove <panel title>? y/n` for the marked panel: `y` reads the board again and removes it through `changeBoard`, as the page's Remove does, unless another door moved or removed panels meanwhile (then nothing is removed and the screen shows the board as it is), `n` or `esc` cancels, and the key is offered only to a forge admin who is a person; a pin on the removed panel is left as the page leaves it. A refusal is shown in the page's words (`boardRefusal`) and nothing changes.
6464 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):
6565 - the page (Pin to home / Unpin on `/boards/:slug`, Up / Down / Unpin on home);
6666 - 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/tui/screens/boards.ts +44 −6
@@ -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. `n` makes a board, `c` copies the marked one and `D` deletes it after `y/n`
6 * door only to that person. `n` makes a board, `c` copies the marked one, `e` renames it and `g` sets
7 * its range (`changeBoard`; `home` still jumps to the top), and `D` deletes it after `y/n`
78 * (`actions/boards.ts`, as the page does). Otherwise reading, but for pins: a chart is not drawn, a panel is said
89 * as its title, chart, size and the query that feeds it, then what `panelData` reads for it
910 * (the page's and the API's door): a time panel's last bucket and its step, else its row count. `[`/`]` mark a panel,
@@ -12,7 +13,7 @@
1213
1314 import { formatValue } from "../../boards/chart.ts";
1415 import { panelData, type PanelData } from "../../boards/panel_data.ts";
15 import type { Board } from "../../boards/store.ts";
16 import { RANGES, type Board } from "../../boards/store.ts";
1617 import { listPinned, movePinned, pinPanel, unpinPanel } from "../../actions/board-pins.ts";
1718 import { boardRefusal, changeBoard, deleteBoard, duplicateBoard, listAllBoards, makeBoard, showBoard, type BoardError } from "../../actions/boards.ts";
1819 import type { Ctx, Screen } from "../app/app.ts";
@@ -32,6 +33,9 @@ export const plain = (text: string): string =>
3233
3334 export interface BoardRow extends ListRow {
3435 readonly slug: string;
36 /** The title and range as stored, for the prompts that edit them. */
37 readonly rawTitle: string;
38 readonly range: string;
3539 }
3640
3741 /** The boards as rows; nothing for anyone the action refuses. */
@@ -40,6 +44,8 @@ export async function boardRows(session: Session): Promise<BoardRow[]> {
4044 if (!listed.ok) return [];
4145 return listed.value.map((b) => ({
4246 slug: b.slug,
47 rawTitle: b.board.title,
48 range: b.board.range,
4349 title: plain(b.board.title),
4450 secondary: b.slug,
4551 meta: metaOf(plural(b.board.panels.length, "panel"), b.board.range),
@@ -244,12 +250,38 @@ export function boardsScreen(f: Session, ctx: Ctx): Screen {
244250 ctx.toast(done.ok ? "board deleted" : refusal(done.error), done.ok ? "success" : "danger");
245251 ctx.invalidate();
246252 });
253 // The gate is `changeBoard`'s, read once as the board screen's is.
254 const mayEdit = f.user?.isAdmin === true && f.user.kind !== "agent";
255 /** Prompt for a new value of the row's board and apply it as the board page's Rename and Settings do. */
256 const edit = (row: BoardRow, what: "title" | "range"): void => {
257 ctx.push(
258 promptScreen(ctx, screen, {
259 title: what === "title" ? "Rename board" : "Board range",
260 label: what === "title" ? "Title" : `Range (${RANGES.join(", ")})`,
261 value: what === "title" ? row.rawTitle : row.range,
262 onSubmit: (value) =>
263 ctx.track((async () => {
264 // The refresh is the board's now, so a range change keeps it.
265 const now = await showBoard(f.app, f.user, row.slug);
266 const done = !now.ok
267 ? now
268 : await changeBoard(f.app, f.user, row.slug, what === "title" ? { kind: "title", title: value } : { kind: "settings", range: value, refresh: now.value.refresh });
269 await read();
270 ctx.toast(done.ok ? (what === "title" ? "renamed" : "range changed") : refusal(done.error), done.ok ? "success" : "danger");
271 ctx.invalidate();
272 })()),
273 }),
274 );
275 };
247276 const screen: Screen = {
248277 title: "Boards",
249 keys: [
250 binding(["j", "k"], "move"), binding(["enter"], "open"), binding(["n"], "new"), binding(["c"], "copy"),
251 binding(["D"], "delete"), binding(["r"], "refresh"),
252 ],
278 get keys() {
279 return [
280 binding(["j", "k"], "move"), binding(["enter"], "open"), binding(["n"], "new"), binding(["c"], "copy"),
281 ...(mayEdit ? [binding(["e"], "rename"), binding(["g"], "range")] : []),
282 binding(["D"], "delete"), binding(["r"], "refresh"),
283 ];
284 },
253285 load: read,
254286 render(area, s) {
255287 const c = ctx.theme.c;
@@ -276,6 +308,12 @@ export function boardsScreen(f: Session, ctx: Ctx): Screen {
276308 }
277309 return true;
278310 }
311 // Before the list's own keys: `g` would jump to the top.
312 if (mayEdit && (e.key === "e" || e.key === "g")) {
313 const row = rows[state.index];
314 if (row !== undefined) edit(row, e.key === "e" ? "title" : "range");
315 return true;
316 }
279317 if (state.key(e, rows.length)) return true;
280318 if (e.key === "enter") {
281319 open(state.index);
test/tui-boards-rename.test.ts added +154
@@ -0,0 +1,154 @@
1 /**
2 * `e` renames and `g` sets the range of the marked board on the Boards list
3 * (#8033), through the action the board page uses; nobody but an admin person
4 * is offered either key.
5 */
6 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { showBoard, writeBoard } from "../src/actions/boards.ts";
11 import { createAgent } from "../src/actions/crew.ts";
12 import { createApp, type App } from "../src/app.ts";
13 import { createUser, type User } from "../src/core/users.ts";
14 import { createForgeApp } from "../src/tui/forge.ts";
15 import { boardsScreen } from "../src/tui/screens/boards.ts";
16 import { headlessTerminal } from "../src/tui/term/terminal.ts";
17 import { quietLogs } from "./helpers/globals.ts";
18 import { settleApp } from "./helpers/tui.ts";
19
20 quietLogs();
21
22 let dir: string;
23 let app: App;
24 let carol: User;
25 let bob: User;
26 let bot: User;
27
28 const identity = (user?: User) => ({
29 sshUsername: user?.username ?? "git",
30 remoteAddress: "127.0.0.1",
31 fingerprint: "SHA256:test",
32 user,
33 });
34
35 async function screenText(user?: User, keys = ""): Promise<string> {
36 const terminal = headlessTerminal(120, 30);
37 const { app: tui, session } = createForgeApp(app, identity(user), { terminal, exitHooks: false });
38 tui.start();
39 tui.ctx.push(boardsScreen(session, tui.ctx));
40 await settleApp(tui);
41 // One key at a time: a key that opens a screen must land before the next is read.
42 for (const key of keys) {
43 terminal.feed(key);
44 await settleApp(tui);
45 }
46 const text = tui.screen.toText();
47 tui.stop?.();
48 return text;
49 }
50
51 beforeAll(async () => {
52 dir = await mkdtemp(join(tmpdir(), "forge-tui-boards-rename-"));
53 app = createApp({ dataDir: dir, port: 0 });
54 const mk = async (username: string, first = false) => {
55 const r = await createUser(app.db, {
56 origin: "seed",
57 trusted: true, username, email: `${username}@forge.local`,
58 password: "a-good-passphrase", adminIfFirst: first,
59 });
60 if (!r.ok) throw new Error(r.error);
61 return r.value;
62 };
63 carol = await mk("carol", true);
64 bob = await mk("bob");
65 const made = await createAgent(app, carol, { username: "boardbot", brief: "x" });
66 if (!made.ok) throw new Error(String(made.error));
67 app.db.query("UPDATE user SET is_admin = 1 WHERE id = ?1").run(made.value.user.id);
68 bot = { ...made.value.user, isAdmin: true };
69 const wrote = await writeBoard(app, carol, "runs", {
70 title: "Runs this week",
71 range: "7d",
72 refresh: "off",
73 panels: [{ title: "Failed runs", chart: "number", query: { source: "runs", aggregate: "count" }, w: 1, h: 1 }],
74 });
75 if (!wrote.ok) throw new Error(JSON.stringify(wrote.error));
76 }, 30_000);
77
78 afterAll(async () => {
79 app.close();
80 await rm(dir, { recursive: true, force: true });
81 });
82
83 const BACK = "\x7f";
84 const board = async () => {
85 const shown = await showBoard(app, carol, "runs");
86 if (!shown.ok) throw new Error("no board");
87 return shown.value;
88 };
89
90 describe("tui boards: rename and range", () => {
91 test("e is prefilled with the title and renames the board", async () => {
92 const opened = await screenText(carol, "e");
93 expect(opened).toContain("Rename board");
94 expect(opened).toContain("Runs this week");
95 expect(await screenText(carol, "e" + BACK.repeat("Runs this week".length) + "Weekly runs\r")).toContain("renamed");
96 expect((await board()).title).toBe("Weekly runs");
97 expect(await screenText(carol)).toContain("Weekly runs");
98 });
99
100 test("an empty or too-long title is refused and the old one stays", async () => {
101 const empty = await screenText(carol, "e" + BACK.repeat(40) + "\r");
102 expect(empty).toContain("That was refused");
103 expect((await board()).title).toBe("Weekly runs");
104 const long = await screenText(carol, "e" + "x".repeat(81) + "\r");
105 expect(long).toContain("That was refused");
106 expect((await board()).title).toBe("Weekly runs");
107 });
108
109 test("g is prefilled with the range and changes it, keeping the refresh", async () => {
110 await writeBoard(app, carol, "runs", { title: "Weekly runs", range: "7d", refresh: "1m", panels: (await board()).panels });
111 const opened = await screenText(carol, "g");
112 expect(opened).toContain("Board range");
113 expect(opened).toContain("7d");
114 expect(await screenText(carol, "g" + BACK + BACK + "24h\r")).toContain("range changed");
115 const now = await board();
116 expect(now.range).toBe("24h");
117 expect(now.refresh).toBe("1m");
118 expect(await screenText(carol, "g" + BACK.repeat(3) + "2w\r")).toContain("That was refused");
119 expect((await board()).range).toBe("24h");
120 });
121
122 test("only an admin person is offered the keys, and they change nothing for anyone else", async () => {
123 expect(await screenText(carol)).toContain("rename");
124 expect(await screenText(carol)).toContain("range");
125 for (const who of [bob, bot, undefined]) {
126 const text = await screenText(who);
127 expect(text).not.toContain("e rename");
128 expect(text).not.toContain("g range");
129 await screenText(who, "eZ\r");
130 await screenText(who, "gx\r");
131 }
132 const now = await board();
133 expect(now.title).toBe("Weekly runs");
134 expect(now.range).toBe("24h");
135 });
136
137 test("g sets the range, so home is the admin's way to the first row", async () => {
138 await writeBoard(app, carol, "zeta", { title: "Zeta", range: "7d", refresh: "off", panels: [] });
139 const text = await screenText(carol, "j");
140 expect(text).toContain("▸ Zeta");
141 const terminal = headlessTerminal(120, 30);
142 const { app: tui, session } = createForgeApp(app, identity(carol), { terminal, exitHooks: false });
143 tui.start();
144 tui.ctx.push(boardsScreen(session, tui.ctx));
145 await settleApp(tui);
146 terminal.feed("j");
147 await settleApp(tui);
148 terminal.feed("\x1b[H");
149 await settleApp(tui);
150 const top = tui.screen.toText();
151 tui.stop?.();
152 expect(top).toContain("▸ Weekly runs");
153 });
154 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs