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

Merged opened this

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

Merged

mason/forge:mason/9815-the-boards-list-and-a-board-s-page-speak → main 2 commits fork

  • Merged. mason/9815-the-boards-list-and-a-board-s-page-speak was merged into main as c8378b7.

Review

Sign in to review this change.
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`.
1818 ## Verified after landing (#7786)
1919
2020 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";
1818 import { notFoundPage, signInFirst } from "../chrome.ts";
1919 import { html, when, type Html } from "../html.ts";
2020 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";
2222 import { flowPanel } from "./flow-panel.ts";
2323 import { boardsHoldsCard } from "./boards-holds.ts";
2424 import { forgeCeiling } from "../../flow/ceiling.ts";
@@ -93,24 +93,24 @@ export async function boardsList(ctx: Ctx): Promise<Response> {
9393 if (holds === null) return notFoundPage(ctx);
9494 return page({ ctx, title: "Boards", body: html`<div class="page-head"><h1>Boards</h1></div>${holds}` });
9595 }
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>${
101102 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>`;
114114 const flow = timePhase(ctx.request, "flow", () => flowPanel(ctx.app, { kind: "owner" }, ctx.url, "/boards"));
115115 const rendering = performance.now();
116116 const response = page({
@@ -120,7 +120,7 @@ export async function boardsList(ctx: Ctx): Promise<Response> {
120120 ${btn("Flow", { href: "/flow", variant: "ghost" })}
121121 ${btn("Explore the data", { href: "/boards/data", variant: "ghost" })}</div>
122122 ${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)}`,
124124 });
125125 notePhase(ctx.request, "render", performance.now() - rendering);
126126 return response;
@@ -164,16 +164,14 @@ function manage(slug: string, board: Board, adding: Html, pins: ReturnType<typeo
164164 ${confirmAction({ action: `/boards/${slug}/remove?index=${String(i)}&title=${encodeURIComponent(p.title)}`, verb: "Remove", size: "sm", label: `Remove ${p.title}` })}
165165 </div>
166166 </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>
171169 ${when(
172170 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>`,
174172 )}
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">
177175 ${field({ label: "Board title", name: "title", id: "board-title", required: true, maxlength: MAX_TITLE, value: board.title })}
178176 ${btn("Rename")}
179177 </form>
@@ -186,9 +184,7 @@ function manage(slug: string, board: Board, adding: Html, pins: ReturnType<typeo
186184 ${btn("Save settings")}
187185 </form>
188186 <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>`;
192188 }
193189
194190 // 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("&lt;b&gt;Mine&lt;/b&gt;");
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs