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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7319-the-terminal-s-boards-screen-makes-a-boa → main 2 commits fork 2 threads · 2 unresolved
5 files
changed +132
−24
docs/STATUS.md +1
| @@ -57,6 +57,7 @@ | ||
| 57 | 57 | `forge/boards`, a repo the forge owns and makes on the first boot with an admin; every save is a commit |
| 58 | 58 | by the admin who made it, and a file that does not validate is not shown. Make, rename, |
| 59 | 59 | 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. | |
| 60 | 61 | 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): |
| 61 | 62 | - the page (Pin to home / Unpin on `/boards/:slug`, Up / Down / Unpin on home); |
| 62 | 63 | - 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"; | ||
| 25 | 25 | |
| 26 | 26 | export type BoardError = StoreError | "not_found" | "no_slug" | "repo" | { refused: string }; |
| 27 | 27 | |
| 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 | ||
| 28 | 43 | function admin(actor: User | undefined): Result<User, "not_found"> { |
| 29 | 44 | return actor?.isAdmin === true && actor.kind !== "agent" ? ok(actor) : err("not_found"); |
| 30 | 45 | } |
src/tui/screens/boards.ts +67 −7
| @@ -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. 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 | |
| 7 | 8 | * as its title, chart, size and the query that feeds it, then what `panelData` reads for it |
| 8 | 9 | * (the page's and the API's door): a time panel's last bucket and its step, else its row count. `[`/`]` mark a panel, |
| 9 | 10 | * `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"; | ||
| 13 | 14 | import { panelData, type PanelData } from "../../boards/panel_data.ts"; |
| 14 | 15 | import type { Board } from "../../boards/store.ts"; |
| 15 | 16 | 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"; | |
| 17 | 18 | import type { Ctx, Screen } from "../app/app.ts"; |
| 18 | 19 | import { binding } from "../app/keys.ts"; |
| 19 | 20 | import type { Session } from "../session.ts"; |
| @@ -21,7 +22,7 @@ import { split, fill, len } from "../ui/layout.ts"; | ||
| 21 | 22 | import { emptyState } from "../ui/widgets.ts"; |
| 22 | 23 | import { style } from "../term/style.ts"; |
| 23 | 24 | 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"; | |
| 25 | 26 | |
| 26 | 27 | /** One line of plain text: a title or a query value may carry escapes the terminal must not obey. */ |
| 27 | 28 | export const plain = (text: string): string => |
| @@ -136,9 +137,14 @@ function boardScreen(ctx: Ctx, f: Session, slug: string, board: Board): Screen { | ||
| 136 | 137 | }; |
| 137 | 138 | } |
| 138 | 139 | |
| 140 | /** A refusal in the page's words, cleaned for the terminal. */ | |
| 141 | export const refusal = (e: BoardError): string => plain(boardRefusal(e)); | |
| 142 | ||
| 139 | 143 | export function boardsScreen(f: Session, ctx: Ctx): Screen { |
| 140 | 144 | const state = new ListState(); |
| 141 | 145 | let rows: BoardRow[] = []; |
| 146 | /** The board `D` asked about, until `y` or `n`. */ | |
| 147 | let asking: BoardRow | undefined; | |
| 142 | 148 | const read = async (): Promise<void> => { |
| 143 | 149 | rows = await boardRows(f); |
| 144 | 150 | state.clamp(rows.length); |
| @@ -152,18 +158,54 @@ export function boardsScreen(f: Session, ctx: Ctx): Screen { | ||
| 152 | 158 | else ctx.push(boardScreen(ctx, f, row.slug, shown.value)); |
| 153 | 159 | }); |
| 154 | 160 | }; |
| 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 = { | |
| 156 | 194 | 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 | ], | |
| 158 | 199 | load: read, |
| 159 | 200 | render(area, s) { |
| 160 | 201 | const c = ctx.theme.c; |
| 161 | 202 | 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); | |
| 163 | 205 | if (rows.length === 0) { |
| 164 | 206 | emptyState(s, body!, c, { |
| 165 | 207 | title: "No boards yet", |
| 166 | body: "Make one on the web at /boards; it shows up here.", | |
| 208 | body: "Press n to make one.", | |
| 167 | 209 | }); |
| 168 | 210 | return; |
| 169 | 211 | } |
| @@ -171,11 +213,28 @@ export function boardsScreen(f: Session, ctx: Ctx): Screen { | ||
| 171 | 213 | hitList(ctx, body!, state, rows.length, { open }); |
| 172 | 214 | }, |
| 173 | 215 | 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 | } | |
| 174 | 225 | if (state.key(e, rows.length)) return true; |
| 175 | 226 | if (e.key === "enter") { |
| 176 | 227 | open(state.index); |
| 177 | 228 | return true; |
| 178 | 229 | } |
| 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 | } | |
| 179 | 238 | if (e.key === "r") { |
| 180 | 239 | runAsync(ctx, async () => { |
| 181 | 240 | await read(); |
| @@ -186,4 +245,5 @@ export function boardsScreen(f: Session, ctx: Ctx): Screen { | ||
| 186 | 245 | return false; |
| 187 | 246 | }, |
| 188 | 247 | }; |
| 248 | return screen; | |
| 189 | 249 | } |
src/web/pages/boards-edit.ts +2 −15
| @@ -6,7 +6,7 @@ | ||
| 6 | 6 | * work to `actions/boards.ts`. |
| 7 | 7 | */ |
| 8 | 8 | import { |
| 9 | changeBoard, deleteBoard, duplicateBoard, makeBoard, type BoardError, | |
| 9 | boardRefusal, changeBoard, deleteBoard, duplicateBoard, makeBoard, type BoardError, | |
| 10 | 10 | } from "../../actions/boards.ts"; |
| 11 | 11 | import type { Edit } from "../../boards/edit.ts"; |
| 12 | 12 | import { movePinned, pinPanel, unpinPanel } from "../../actions/board-pins.ts"; |
| @@ -19,19 +19,6 @@ import { PANEL_FIELDS, readParams, toQuery } from "./boards.ts"; | ||
| 19 | 19 | |
| 20 | 20 | const num = (v: string) => (/^-?\d{1,4}$/.test(v) ? Number(v) : NaN); |
| 21 | 21 | |
| 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 | ||
| 35 | 22 | /** A panel as the form says it: title, size, chart, and the query the shared fields build. */ |
| 36 | 23 | function panelOf(form: Form): Record<string, unknown> { |
| 37 | 24 | const search = new URLSearchParams(); |
| @@ -57,7 +44,7 @@ function editOf(op: string, form: Form): Edit | undefined { | ||
| 57 | 44 | |
| 58 | 45 | const done = (ctx: Ctx, to: string, message: string) => flashTo(ctx, to, { message }); |
| 59 | 46 | 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) }); | |
| 61 | 48 | |
| 62 | 49 | // POST /boards/new |
| 63 | 50 | 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"; | ||
| 8 | 8 | import { tmpdir } from "node:os"; |
| 9 | 9 | import { join } from "node:path"; |
| 10 | 10 | 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"; | |
| 12 | 12 | import { createAgent } from "../src/actions/crew.ts"; |
| 13 | 13 | import { createApp, type App } from "../src/app.ts"; |
| 14 | 14 | import { createUser, type User } from "../src/core/users.ts"; |
| 15 | 15 | import { createForgeApp, forgeCommands, Session } from "../src/tui/forge.ts"; |
| 16 | 16 | 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"; | |
| 18 | 18 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; |
| 19 | 19 | import { quietLogs } from "./helpers/globals.ts"; |
| 20 | 20 | import { settleApp } from "./helpers/tui.ts"; |
| @@ -189,4 +189,49 @@ describe("tui boards", () => { | ||
| 189 | 189 | unpinPanel(app, carol, "runs", "Failed runs"); |
| 190 | 190 | unpinPanel(app, carol, "runs", "Slow runs"); |
| 191 | 191 | }); |
| 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 |
||
| 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 | }); | |
| 192 | 237 | }); |
new above
offline
This is a copy of
sentence()insrc/web/pages/boards-edit.ts. Keep one map shared by both doors (exported besideBoardError) so the page and the terminal can't drift apart. Wrap therefusedtext inplain()on this side only.