Public: anyone can read this; the crew can change it.
The /boards list and a board's page speak the home page's language #9867
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/9815-the-boards-list-and-a-board-s-page-speak → main 2 commits fork
3 files
changed +108
−29
docs/design/areas/boards.md +4
| @@ -18,3 +18,7 @@ target with 8px of padding. Defined in `src/web/style/components.ts`. | ||
| 18 | 18 | ## Verified after landing (#7786) |
| 19 | 19 | |
| 20 | 20 | The card under the Pipeline table (`id="verified"`) adds no class: it is a `card()` with an `<h2>`, one `<p>` per repository in the default text colour, the repository and its run as plain `<a>` links, `small` for the run link. It is server-rendered, needs no JavaScript, and has no accent of its own. |
| 21 | ||
| 22 | ## The list and a board's management (#9815) | |
| 23 | ||
| 24 | `/boards` and the management under a board's grid speak the home page's language: no card boxes. `boardsList` is a `settings-block` section "Your boards" with one `row()` per board (title, then panels · range · refresh), or one plain sentence saying what a board is when there are none; the make-a-board form sits in its own "Make a board" section. Under a board, Panels, Add a panel and Board are `settings-block` sections with `<h2>` headings. The panels in the grid and the held-files card keep their `card` shape. No new class. |
src/web/pages/boards-view.ts +25 −29
| @@ -18,7 +18,7 @@ import { liveRegionsJs } from "../client.ts"; | ||
| 18 | 18 | import { notFoundPage, signInFirst } from "../chrome.ts"; |
| 19 | 19 | import { html, when, type Html } from "../html.ts"; |
| 20 | 20 | import { fragment, page } from "../layout.ts"; |
| 21 | import { btn, card, chartBox, confirmAction, empty, field, selectField } from "../ui.ts"; | |
| 21 | import { btn, chartBox, confirmAction, field, row, selectField } from "../ui.ts"; | |
| 22 | 22 | import { flowPanel } from "./flow-panel.ts"; |
| 23 | 23 | import { boardsHoldsCard } from "./boards-holds.ts"; |
| 24 | 24 | import { forgeCeiling } from "../../flow/ceiling.ts"; |
| @@ -93,24 +93,24 @@ export async function boardsList(ctx: Ctx): Promise<Response> { | ||
| 93 | 93 | if (holds === null) return notFoundPage(ctx); |
| 94 | 94 | return page({ ctx, title: "Boards", body: html`<div class="page-head"><h1>Boards</h1></div>${holds}` }); |
| 95 | 95 | } |
| 96 | const make = html`<form method="post" action="/boards/new" class="bar"> | |
| 97 | ${field({ label: "New board", name: "title", id: "new-board-title", required: true, maxlength: MAX_TITLE, placeholder: "Title" })} | |
| 98 | ${btn("Make board", { variant: "primary" })} | |
| 99 | </form>`; | |
| 100 | const body = | |
| 96 | const make = html`<section class="settings-block" id="boards-new"><h2>Make a board</h2> | |
| 97 | <form method="post" action="/boards/new" class="bar"> | |
| 98 | ${field({ label: "New board", name: "title", id: "new-board-title", required: true, maxlength: MAX_TITLE, placeholder: "Title" })} | |
| 99 | ${btn("Make board", { variant: "primary" })} | |
| 100 | </form></section>`; | |
| 101 | const body = html`<section class="settings-block" id="boards-all"><h2>Your boards</h2>${ | |
| 101 | 102 | listed.value.length === 0 |
| 102 | ? empty({ icon: "activity", title: "No boards yet", note: "Make one above; it is saved as a file in forge/boards." }) | |
| 103 | : card( | |
| 104 | html`<ul class="list"> | |
| 105 | ${listed.value.map( | |
| 106 | ({ slug, board }) => html`<li class="row"> | |
| 107 | <a href="/boards/${slug}"><strong>${board.title}</strong></a> | |
| 108 | <span class="muted small">${String(board.panels.length)} panel${board.panels.length === 1 ? "" : "s"} · ${board.range} · refresh ${board.refresh}</span> | |
| 109 | </li>`, | |
| 110 | )} | |
| 111 | </ul>`, | |
| 112 | { pad: false }, | |
| 113 | ); | |
| 103 | ? html`<p class="muted">A board is a page of charts over the forge's own numbers. Make one below; it is saved as a file in forge/boards.</p>` | |
| 104 | : html`<ul class="list setting-list"> | |
| 105 | ${listed.value.map(({ slug, board }) => | |
| 106 | row({ | |
| 107 | href: `/boards/${slug}`, | |
| 108 | title: board.title, | |
| 109 | meta: html`${String(board.panels.length)} panel${board.panels.length === 1 ? "" : "s"} · ${board.range} · refresh ${board.refresh}`, | |
| 110 | }), | |
| 111 | )} | |
| 112 | </ul>` | |
| 113 | }</section>`; | |
| 114 | 114 | const flow = timePhase(ctx.request, "flow", () => flowPanel(ctx.app, { kind: "owner" }, ctx.url, "/boards")); |
| 115 | 115 | const rendering = performance.now(); |
| 116 | 116 | const response = page({ |
| @@ -120,7 +120,7 @@ export async function boardsList(ctx: Ctx): Promise<Response> { | ||
| 120 | 120 | ${btn("Flow", { href: "/flow", variant: "ghost" })} |
| 121 | 121 | ${btn("Explore the data", { href: "/boards/data", variant: "ghost" })}</div> |
| 122 | 122 | ${alarmLines(cachedAlarms(ctx.app.db, new Date(), forgeCeiling(ctx.app.config)))} |
| 123 | ${card(make)} ${body} ${flow} ${boardsHoldsCard(ctx.app.db, ctx.auth.user, true)}`, | |
| 123 | ${body} ${make} ${flow} ${boardsHoldsCard(ctx.app.db, ctx.auth.user, true)}`, | |
| 124 | 124 | }); |
| 125 | 125 | notePhase(ctx.request, "render", performance.now() - rendering); |
| 126 | 126 | return response; |
| @@ -164,16 +164,14 @@ function manage(slug: string, board: Board, adding: Html, pins: ReturnType<typeo | ||
| 164 | 164 | ${confirmAction({ action: `/boards/${slug}/remove?index=${String(i)}&title=${encodeURIComponent(p.title)}`, verb: "Remove", size: "sm", label: `Remove ${p.title}` })} |
| 165 | 165 | </div> |
| 166 | 166 | </li>`; |
| 167 | return html`${card( | |
| 168 | html`<ul class="list setting-list">${board.panels.map(one)}</ul>`, | |
| 169 | { head: html`<h2>Panels</h2>`, pad: false }, | |
| 170 | )} | |
| 167 | return html`<section class="settings-block" id="board-panels"><h2>Panels</h2> | |
| 168 | <ul class="list setting-list">${board.panels.map(one)}</ul></section> | |
| 171 | 169 | ${when( |
| 172 | 170 | board.panels.length < MAX_PANELS, |
| 173 | () => card(panelForm(slug, "add", undefined, 0, adding), { head: html`<h2>Add a panel</h2>` }), | |
| 171 | () => html`<section class="settings-block" id="board-add"><h2>Add a panel</h2>${panelForm(slug, "add", undefined, 0, adding)}</section>`, | |
| 174 | 172 | )} |
| 175 | ${card( | |
| 176 | html`<div class="bar"><form method="post" action="/boards/${slug}/rename" class="bar"> | |
| 173 | <section class="settings-block" id="board-manage"><h2>Board</h2> | |
| 174 | <div class="bar"><form method="post" action="/boards/${slug}/rename" class="bar"> | |
| 177 | 175 | ${field({ label: "Board title", name: "title", id: "board-title", required: true, maxlength: MAX_TITLE, value: board.title })} |
| 178 | 176 | ${btn("Rename")} |
| 179 | 177 | </form> |
| @@ -186,9 +184,7 @@ function manage(slug: string, board: Board, adding: Html, pins: ReturnType<typeo | ||
| 186 | 184 | ${btn("Save settings")} |
| 187 | 185 | </form> |
| 188 | 186 | <form method="post" action="/boards/${slug}/duplicate">${btn("Duplicate")}</form> |
| 189 | ${confirmAction({ action: `/boards/${slug}/delete`, verb: "Delete this board" })}</div>`, | |
| 190 | { head: html`<h2>Board</h2>` }, | |
| 191 | )}`; | |
| 187 | ${confirmAction({ action: `/boards/${slug}/delete`, verb: "Delete this board" })}</div></section>`; | |
| 192 | 188 | } |
| 193 | 189 | |
| 194 | 190 | // GET /boards/:slug |
test/boards-list-language.test.ts added +79
| @@ -0,0 +1,79 @@ | ||
| 1 | /** /boards and a board's management speak the home page's language (#9815): headings and rows, no card boxes around them. */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp } from "../src/app.ts"; | |
| 7 | import { createToken } from "../src/core/tokens.ts"; | |
| 8 | import { createUser } from "../src/core/users.ts"; | |
| 9 | import { serve } from "../src/http/server.ts"; | |
| 10 | ||
| 11 | let dir: string; | |
| 12 | let app: ReturnType<typeof createApp>; | |
| 13 | let servers: Awaited<ReturnType<typeof serve>>; | |
| 14 | let token: string; | |
| 15 | const call = (path: string, fields?: Record<string, string>) => | |
| 16 | fetch(servers.url + path, { | |
| 17 | redirect: "manual", | |
| 18 | ...(fields ? { method: "POST", body: new URLSearchParams(fields) } : {}), | |
| 19 | headers: { authorization: `Bearer ${token}` }, | |
| 20 | }); | |
| 21 | ||
| 22 | beforeAll(async () => { | |
| 23 | dir = await mkdtemp(join(tmpdir(), "forge-boards-lang-")); | |
| 24 | app = createApp({ dataDir: dir, port: 0 }); | |
| 25 | servers = await serve(app); | |
| 26 | const r = await createUser(app.db, { | |
| 27 | origin: "seed", trusted: true, username: "alice", email: "[email protected]", | |
| 28 | password: "a-good-passphrase", adminIfFirst: true, | |
| 29 | }); | |
| 30 | if (!r.ok) throw new Error(r.error); | |
| 31 | token = createToken(app.db, r.value.id, "t", "admin").token; | |
| 32 | }); | |
| 33 | afterAll(async () => { | |
| 34 | servers.stop(); | |
| 35 | app.close(); | |
| 36 | await rm(dir, { recursive: true, force: true }); | |
| 37 | }); | |
| 38 | ||
| 39 | /** The page's own sections: the ones the issue names, up to the flow panel and held card. */ | |
| 40 | const block = (html: string, id: string) => { | |
| 41 | const at = html.indexOf(`id="${id}"`); | |
| 42 | expect(at, id).toBeGreaterThan(-1); | |
| 43 | return html.slice(at, html.indexOf("</section>", at)); | |
| 44 | }; | |
| 45 | ||
| 46 | test("/boards with no board: a heading, one plain sentence, the form under its own heading", async () => { | |
| 47 | const html = await (await call("/boards")).text(); | |
| 48 | const all = block(html, "boards-all"); | |
| 49 | expect(all).toContain("<h2>Your boards</h2>"); | |
| 50 | expect(all).toContain("A board is a page of charts"); | |
| 51 | expect(all).not.toContain("card"); | |
| 52 | expect(block(html, "boards-new")).toContain("<h2>Make a board</h2>"); | |
| 53 | expect(block(html, "boards-new")).not.toContain("card"); | |
| 54 | }); | |
| 55 | ||
| 56 | test("/boards with a board: one row under the heading, the name escaped, no card", async () => { | |
| 57 | expect((await call("/boards/new", { title: "<b>Mine</b>" })).status).toBe(303); | |
| 58 | const html = await (await call("/boards")).text(); | |
| 59 | const all = block(html, "boards-all"); | |
| 60 | expect(all).toContain("<h2>Your boards</h2>"); | |
| 61 | expect(all).toContain("<b>Mine</b>"); | |
| 62 | expect(all).not.toContain("<b>Mine"); | |
| 63 | expect(all.match(/<li/g)?.length).toBe(1); | |
| 64 | expect(all).not.toContain("card"); | |
| 65 | expect(all).not.toContain("A board is a page of charts"); | |
| 66 | }); | |
| 67 | ||
| 68 | test("a board's management is sections with headings; its panels keep their cards", async () => { | |
| 69 | const slug = (await (await call("/boards")).text()).match(/<li[^>]*>\s*<a[^>]*href="\/boards\/([^"]+)"/)![1]!; | |
| 70 | const q = { source: "runs", aggregate: "count", column: "", by: "day", from: "", to: "", fcolumn: "state", fop: "eq", fvalue: "ok" }; | |
| 71 | expect((await call(`/boards/${slug}/add`, { ...q, title: "P", w: "1", h: "1" })).status).toBe(303); | |
| 72 | const html = await (await call(`/boards/${slug}`)).text(); | |
| 73 | expect(html).toContain('class="card bp-w1'); | |
| 74 | for (const [id, h] of [["board-panels", "Panels"], ["board-add", "Add a panel"], ["board-manage", "Board"]] as const) { | |
| 75 | const b = block(html, id); | |
| 76 | expect(b).toContain(`<h2>${h}</h2>`); | |
| 77 | expect(b).not.toContain('class="card'); | |
| 78 | } | |
| 79 | }); |