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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3907-design-a-board-s-view-and-its-forms-draw → main 4 commits fork 2 threads · 2 unresolved
4 files
changed +90
−16
src/web/pages/boards-view.ts +15 −15
| @@ -16,9 +16,9 @@ import type { Ctx } from "../../http/context.ts"; | ||
| 16 | 16 | import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts"; |
| 17 | 17 | import { liveRegionsJs } from "../client.ts"; |
| 18 | 18 | import { notFoundPage } from "../chrome.ts"; |
| 19 | import { html, raw, when, type Html } from "../html.ts"; | |
| 19 | import { html, when, type Html } from "../html.ts"; | |
| 20 | 20 | 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"; | |
| 22 | 22 | import { flowPanel } from "./flow-panel.ts"; |
| 23 | 23 | import { isPinned, listPins } from "../../boards/pins.ts"; |
| 24 | 24 | import { panelData, runFor } from "../../boards/panel_data.ts"; |
| @@ -27,8 +27,8 @@ import { cellsOf, paramsOf, queryFields, readParams } from "./boards.ts"; | ||
| 27 | 27 | /** The events the panels' queries read (runs, pulls, checks, issues, usage): any of them may change a chart. */ |
| 28 | 28 | const READS = "run.queued,run.started,run.finished,pull.*,check.*,issue.*,usage.*"; |
| 29 | 29 | |
| 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 })); | |
| 32 | 32 | |
| 33 | 33 | /** Where a panel lives: its board's slug and its place on it, which name its data door. */ |
| 34 | 34 | export type PanelAt = { slug: string; index: number }; |
| @@ -84,7 +84,7 @@ export async function boardsList(ctx: Ctx): Promise<Response> { | ||
| 84 | 84 | const listed = await timePhaseAsync(ctx.request, "boards", () => listAllBoards(ctx.app, ctx.auth.user)); |
| 85 | 85 | if (!listed.ok) return notFoundPage(ctx); |
| 86 | 86 | const make = html`<form method="post" action="/boards/new" class="bform"> |
| 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" })} | |
| 88 | 88 | ${btn("Make board", { variant: "primary" })} |
| 89 | 89 | </form>`; |
| 90 | 90 | const body = |
| @@ -115,15 +115,12 @@ export async function boardsList(ctx: Ctx): Promise<Response> { | ||
| 115 | 115 | } |
| 116 | 116 | |
outdated
Same for the panel form: |
||
| 117 | 117 | function panelForm(slug: string, op: string, p: Panel | undefined, index: number, fields: Html): Html { |
| 118 | const id = op === "edit" ? `panel-${String(index)}` : "panel-new"; | |
| 118 | 119 | return html`<form method="post" action="/boards/${slug}/${op}" class="bform"> |
| 119 | 120 | ${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) })} | |
| 127 | 124 | ${fields} |
| 128 | 125 | ${btn(op === "edit" ? "Save panel" : "Add panel", { variant: "primary" })} |
| 129 | 126 | </form>`; |
| @@ -168,12 +165,15 @@ function manage(slug: string, board: Board, adding: Html, pins: ReturnType<typeo | ||
| 168 | 165 | )} |
| 169 | 166 | ${card( |
| 170 | 167 | 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 })} | |
| 172 | 169 | ${btn("Rename")} |
| 173 | 170 | </form> |
| 174 | 171 | <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 | ] })} | |
| 177 | 177 | ${btn("Save settings")} |
| 178 | 178 | </form> |
| 179 | 179 | <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); } | ||
| 297 | 297 | .bform { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 10px 12px; margin-bottom: 8px; } |
| 298 | 298 | .bform .qf { display: flex; flex-direction: column; gap: 4px; flex: 1 1 140px; min-width: 0; } |
| 299 | 299 | .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; } | |
| 300 | 301 | .bform .btn { flex: none; } |
| 301 | 302 | .boards-result .table { margin-top: 12px; } |
| 302 | 303 | .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", () => { | ||
| 192 | 192 | expect(grid).toMatch(/data-live="[^"]+"/); |
| 193 | 193 | expect(grid).toContain('data-live="run.queued,run.started,run.finished,pull.*,check.*,issue.*,usage.*"'); |
| 194 | 194 | 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>/); | |
| 196 | 196 | expect(body).not.toContain(">1m</option>"); |
| 197 | 197 | const live = await asAdmin("/boards/fleet-health/live"); |
| 198 | 198 | 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 | }); |
new above
offline
Use
field({label:"New board", name:"title", id:"new-board-title", required:true, maxlength:MAX_TITLE, placeholder:"Title"})instead of hand-rolled.fieldmarkup.