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

Design: a board's view and its forms draw their fields as §5 `.field`, not `.qf` #3911

Merged opened this

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

Merged

scout/forge:scout/3907-design-a-board-s-view-and-its-forms-draw → main 4 commits fork 2 threads · 2 unresolved

  • Merged. scout/3907-design-a-board-s-view-and-its-forms-draw was merged into main as e19d20b.

Review

Sign in to review this change.
4 files changed +90 −16
src/web/pages/boards-view.ts +15 −15
@@ -16,9 +16,9 @@ import type { Ctx } from "../../http/context.ts";
1616 import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts";
1717 import { liveRegionsJs } from "../client.ts";
1818 import { notFoundPage } from "../chrome.ts";
19 import { html, raw, when, type Html } from "../html.ts";
19 import { html, when, type Html } from "../html.ts";
2020 import { fragment, page } from "../layout.ts";
21 import { btn, card, chartBox, empty } from "../ui.ts";
21 import { btn, card, chartBox, empty, field, selectField } from "../ui.ts";
2222 import { flowPanel } from "./flow-panel.ts";
2323 import { isPinned, listPins } from "../../boards/pins.ts";
2424 import { panelData, runFor } from "../../boards/panel_data.ts";
@@ -27,8 +27,8 @@ import { cellsOf, paramsOf, queryFields, readParams } from "./boards.ts";
2727 /** The events the panels' queries read (runs, pulls, checks, issues, usage): any of them may change a chart. */
2828 const READS = "run.queued,run.started,run.finished,pull.*,check.*,issue.*,usage.*";
2929
30 const option = (value: string, selected: boolean) =>
31 html`<option value="${value}" ${selected ? raw("selected") : null}>${value}</option>`;
30 const sizes = (max: number, word: string, now: number) =>
31 [1, 2, 3].slice(0, max).map((n) => ({ value: String(n), label: `${String(n)} ${word}`, selected: n === now }));
3232
3333 /** Where a panel lives: its board's slug and its place on it, which name its data door. */
3434 export type PanelAt = { slug: string; index: number };
@@ -84,7 +84,7 @@ export async function boardsList(ctx: Ctx): Promise<Response> {
8484 const listed = await timePhaseAsync(ctx.request, "boards", () => listAllBoards(ctx.app, ctx.auth.user));
8585 if (!listed.ok) return notFoundPage(ctx);
8686 const make = html`<form method="post" action="/boards/new" class="bform">
outdated

Use field({label:"New board", name:"title", id:"new-board-title", required:true, maxlength:MAX_TITLE, placeholder:"Title"}) instead of hand-rolled .field markup.

87 <label class="qf"><span>New board</span><input class="input" name="title" required maxlength="${String(MAX_TITLE)}" placeholder="Title" /></label>
87 ${field({ label: "New board", name: "title", id: "new-board-title", required: true, maxlength: MAX_TITLE, placeholder: "Title" })}
8888 ${btn("Make board", { variant: "primary" })}
8989 </form>`;
9090 const body =
@@ -115,15 +115,12 @@ export async function boardsList(ctx: Ctx): Promise<Response> {
115115 }
116116
outdated

Same for the panel form: field() for the title, selectField() for Width and Height, with the per-panel ids. That drops the repeated markup.

117117 function panelForm(slug: string, op: string, p: Panel | undefined, index: number, fields: Html): Html {
118 const id = op === "edit" ? `panel-${String(index)}` : "panel-new";
118119 return html`<form method="post" action="/boards/${slug}/${op}" class="bform">
119120 ${op === "edit" ? html`<input type="hidden" name="index" value="${String(index)}" />` : null}
120 <label class="qf"><span>Panel title</span><input class="input" name="title" required maxlength="${String(MAX_TITLE)}" value="${p?.title ?? ""}" placeholder="Panel title" /></label>
121 <label class="qf"><span>Width</span><select class="input" name="w">
122 ${[1, 2, 3].slice(0, MAX_W).map((n) => html`<option value="${String(n)}" ${n === (p?.w ?? 1) ? raw("selected") : null}>${String(n)} wide</option>`)}
123 </select></label>
124 <label class="qf"><span>Height</span><select class="input" name="h">
125 ${[1, 2, 3].slice(0, MAX_H).map((n) => html`<option value="${String(n)}" ${n === (p?.h ?? 1) ? raw("selected") : null}>${String(n)} high</option>`)}
126 </select></label>
121 ${field({ label: "Panel title", name: "title", id: `${id}-title`, required: true, maxlength: MAX_TITLE, value: p?.title ?? "", placeholder: "Panel title" })}
122 ${selectField({ label: "Width", name: "w", id: `${id}-w`, options: sizes(MAX_W, "wide", p?.w ?? 1) })}
123 ${selectField({ label: "Height", name: "h", id: `${id}-h`, options: sizes(MAX_H, "high", p?.h ?? 1) })}
127124 ${fields}
128125 ${btn(op === "edit" ? "Save panel" : "Add panel", { variant: "primary" })}
129126 </form>`;
@@ -168,12 +165,15 @@ function manage(slug: string, board: Board, adding: Html, pins: ReturnType<typeo
168165 )}
169166 ${card(
170167 html`<form method="post" action="/boards/${slug}/rename" class="bform">
171 <label class="qf"><span>Board title</span><input class="input" name="title" required maxlength="${String(MAX_TITLE)}" value="${board.title}" /></label>
168 ${field({ label: "Board title", name: "title", id: "board-title", required: true, maxlength: MAX_TITLE, value: board.title })}
172169 ${btn("Rename")}
173170 </form>
174171 <form method="post" action="/boards/${slug}/settings" class="bform">
175 <label class="qf"><span>Range</span><select class="input" name="range">${RANGES.map((r) => option(r, r === board.range))}</select></label>
176 <label class="qf"><span>Live</span><select class="input" name="refresh"><option value="off" ${board.refresh === "off" ? raw("selected") : null}>off</option><option value="10s" ${board.refresh === "off" ? null : raw("selected")}>on: redraw when events arrive</option></select></label>
172 ${selectField({ label: "Range", name: "range", id: "board-range", options: RANGES.map((r) => ({ value: r, label: r, selected: r === board.range })) })}
173 ${selectField({ label: "Live", name: "refresh", id: "board-refresh", options: [
174 { value: "off", label: "off", selected: board.refresh === "off" },
175 { value: "10s", label: "on: redraw when events arrive", selected: board.refresh !== "off" },
176 ] })}
177177 ${btn("Save settings")}
178178 </form>
179179 <form method="post" action="/boards/${slug}/duplicate" class="bform">${btn("Duplicate")}</form>
src/web/style/content.ts +1
@@ -297,6 +297,7 @@ a.origin-chip:hover { border-color: var(--border-strong); color: var(--fg); }
297297 .bform { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 10px 12px; margin-bottom: 8px; }
298298 .bform .qf { display: flex; flex-direction: column; gap: 4px; flex: 1 1 140px; min-width: 0; }
299299 .bform .qf > span { font-size: 12.5px; font-weight: 600; color: var(--fg); }
300 .bform .field { margin: 0; flex: 1 1 140px; min-width: 0; }
300301 .bform .btn { flex: none; }
301302 .boards-result .table { margin-top: 12px; }
302303 .bt-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 300px), 1fr)); gap: 2px 16px; font-family: var(--mono); font-size: 12.5px; margin-top: 8px; }
test/boards-store.test.ts +1 −1
@@ -192,7 +192,7 @@ describe("/boards", () => {
192192 expect(grid).toMatch(/data-live="[^"]+"/);
193193 expect(grid).toContain('data-live="run.queued,run.started,run.finished,pull.*,check.*,issue.*,usage.*"');
194194 expect(body).toContain('data-live-src="/boards/fleet-health/live"');
195 expect(body).toMatch(/<option value="10s" selected>on: redraw when events arrive<\/option>/);
195 expect(body).toMatch(/<option\s+value="10s"\s+selected\s*>\s*on: redraw when events arrive\s*<\/option>/);
196196 expect(body).not.toContain(">1m</option>");
197197 const live = await asAdmin("/boards/fleet-health/live");
198198 expect(live.status).toBe(200);
test/boards-view-fields.test.ts added +73
@@ -0,0 +1,73 @@
1 /**
2 * A board's view and its forms draw their fields as `.field` with a real
3 * `<label for>`, not the `.qf` wrapper. (The query fields in boards.ts keep
4 * `.qf` until their own issue, so those are left out of the check.)
5 */
6 import { afterAll, beforeAll, 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 { createApp } from "../src/app.ts";
11 import { createToken } from "../src/core/tokens.ts";
12 import { createUser } from "../src/core/users.ts";
13 import { serve } from "../src/http/server.ts";
14
15 let dir: string;
16 let app: ReturnType<typeof createApp>;
17 let servers: Awaited<ReturnType<typeof serve>>;
18 let token: string;
19
20 const call = (path: string, fields?: Record<string, string>) =>
21 fetch(servers.url + path, {
22 redirect: "manual",
23 ...(fields ? { method: "POST", body: new URLSearchParams(fields) } : {}),
24 headers: { authorization: `Bearer ${token}` },
25 });
26
27 beforeAll(async () => {
28 dir = await mkdtemp(join(tmpdir(), "forge-boards-view-fields-"));
29 app = createApp({ dataDir: dir, port: 0 });
30 servers = await serve(app);
31 const r = await createUser(app.db, {
32 origin: "seed", trusted: true, username: "alice", email: "[email protected]",
33 password: "a-good-passphrase", adminIfFirst: true,
34 });
35 if (!r.ok) throw new Error(r.error);
36 token = createToken(app.db, r.value.id, "t", "admin").token;
37 });
38
39 afterAll(async () => {
40 servers.stop();
41 app.close();
42 await rm(dir, { recursive: true, force: true });
43 });
44
45 const LABELS = ["New board", "Panel title", "Width", "Height", "Board title", "Range", "Live"];
46
47 function check(html: string, labels: string[]) {
48 for (const text of labels) {
49 expect(html, text).not.toContain(`<label class="qf"><span>${text}</span>`);
50 const m = html.match(new RegExp(`<div class="field"[^>]*>\\s*<label for="([^"]+)">${text}</label>`));
51 expect(m, text).not.toBeNull();
52 expect(html.match(new RegExp(`id="${m![1]}"`, "g"))?.length, `${text} id is unique`).toBe(1);
53 }
54 const ids = new Set([...html.matchAll(/ id="([^"]+)"/g)].map((m) => m[1]));
55 for (const m of html.matchAll(/<label for="([^"]+)"/g)) expect(ids.has(m[1]!), m[1]).toBe(true);
56 }
57
58 test("the boards list draws its new-board field as .field", async () => {
59 const html = await (await call("/boards")).text();
60 check(html, ["New board"]);
61 });
62
63 test("a board's forms draw every field as .field with a unique id", async () => {
64 expect((await call("/boards/new", { title: "Fields" })).status).toBe(303);
65 const q = { source: "runs", aggregate: "count", column: "", by: "day", from: "", to: "", fcolumn: "state", fop: "eq", fvalue: "ok" };
66 for (const title of ["one", "two"]) expect((await call("/boards/fields/add", { ...q, title, w: "1", h: "1" })).status).toBe(303);
67 const html = await (await call("/boards/fields")).text();
68 check(html, LABELS.slice(1));
69 expect(html).toContain('id="panel-0-title"');
70 expect(html).toContain('id="panel-1-title"');
71 expect(html).toContain('id="panel-new-title"');
72 for (const name of ["title", "w", "h", "range", "refresh"]) expect(html).toContain(`name="${name}"`);
73 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs