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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/8033-the-terminal-s-board-list-renames-the-se → main 2 commits fork
3 files
changed +199
−7
docs/STATUS.md +1 −1
| @@ -60,7 +60,7 @@ | ||
| 60 | 60 | `forge/boards`, a repo the forge owns and makes on the first boot with an admin; every save is a commit |
| 61 | 61 | by the admin who made it, and a file that does not validate is not shown. Make, rename, |
| 62 | 62 | 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. | |
| 64 | 64 | 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): |
| 65 | 65 | - the page (Pin to home / Unpin on `/boards/:slug`, Up / Down / Unpin on home); |
| 66 | 66 | - 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 @@ | ||
| 3 | 3 | * keeps, then one board's panels as text. It reads through the same |
| 4 | 4 | * `listAllBoards` and `showBoard` the page and the API do, so the gate is the |
| 5 | 5 | * 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` | |
| 7 | 8 | * (`actions/boards.ts`, as the page does). Otherwise reading, but for pins: a chart is not drawn, a panel is said |
| 8 | 9 | * as its title, chart, size and the query that feeds it, then what `panelData` reads for it |
| 9 | 10 | * (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 @@ | ||
| 12 | 13 | |
| 13 | 14 | import { formatValue } from "../../boards/chart.ts"; |
| 14 | 15 | 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"; | |
| 16 | 17 | import { listPinned, movePinned, pinPanel, unpinPanel } from "../../actions/board-pins.ts"; |
| 17 | 18 | import { boardRefusal, changeBoard, deleteBoard, duplicateBoard, listAllBoards, makeBoard, showBoard, type BoardError } from "../../actions/boards.ts"; |
| 18 | 19 | import type { Ctx, Screen } from "../app/app.ts"; |
| @@ -32,6 +33,9 @@ export const plain = (text: string): string => | ||
| 32 | 33 | |
| 33 | 34 | export interface BoardRow extends ListRow { |
| 34 | 35 | readonly slug: string; |
| 36 | /** The title and range as stored, for the prompts that edit them. */ | |
| 37 | readonly rawTitle: string; | |
| 38 | readonly range: string; | |
| 35 | 39 | } |
| 36 | 40 | |
| 37 | 41 | /** The boards as rows; nothing for anyone the action refuses. */ |
| @@ -40,6 +44,8 @@ export async function boardRows(session: Session): Promise<BoardRow[]> { | ||
| 40 | 44 | if (!listed.ok) return []; |
| 41 | 45 | return listed.value.map((b) => ({ |
| 42 | 46 | slug: b.slug, |
| 47 | rawTitle: b.board.title, | |
| 48 | range: b.board.range, | |
| 43 | 49 | title: plain(b.board.title), |
| 44 | 50 | secondary: b.slug, |
| 45 | 51 | meta: metaOf(plural(b.board.panels.length, "panel"), b.board.range), |
| @@ -244,12 +250,38 @@ export function boardsScreen(f: Session, ctx: Ctx): Screen { | ||
| 244 | 250 | ctx.toast(done.ok ? "board deleted" : refusal(done.error), done.ok ? "success" : "danger"); |
| 245 | 251 | ctx.invalidate(); |
| 246 | 252 | }); |
| 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 | }; | |
| 247 | 276 | const screen: Screen = { |
| 248 | 277 | 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 | }, | |
| 253 | 285 | load: read, |
| 254 | 286 | render(area, s) { |
| 255 | 287 | const c = ctx.theme.c; |
| @@ -276,6 +308,12 @@ export function boardsScreen(f: Session, ctx: Ctx): Screen { | ||
| 276 | 308 | } |
| 277 | 309 | return true; |
| 278 | 310 | } |
| 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 | } | |
| 279 | 317 | if (state.key(e, rows.length)) return true; |
| 280 | 318 | if (e.key === "enter") { |
| 281 | 319 | 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 | }); |
new above
offline