Public: anyone can read this; the crew can change it.
The /boards chart and pin forms wrap each input in §5's .field, and no .qf remains in src/web/pages/boards.ts #4719
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/4689-the-boards-chart-and-pin-forms-wrap-each → main 2 commits fork 1 thread · 1 unresolved
4 files
changed +51
−65
src/web/pages/boards.ts +27 −59
| @@ -18,7 +18,7 @@ import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../cor | ||
| 18 | 18 | import { notFoundPage } from "../chrome.ts"; |
| 19 | 19 | import { html, raw, when } from "../html.ts"; |
| 20 | 20 | import { 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 | |
| 23 | 23 | const BUCKET_MS = { minute: 60_000, hour: 3_600_000, day: 86_400_000 } as const; |
| 24 | 24 | const AGGREGATES = ["count", "sum", "avg", "max"] as const; |
| @@ -137,64 +137,34 @@ const option = (value: string, label: string, selected: boolean) => | ||
| 137 | 137 | html`<option value="${value}" ${selected ? raw("selected") : null}>${label}</option>`; |
| 138 | 138 | |
| 139 | 139 | /** The inputs that say a query, filled from `p`; the page's form and a panel's share them. */ |
| 140 | let fieldSeq = 0; | |
| 141 | ||
| 140 | 142 | export function queryFields(p: Params) { |
| 141 | 143 | const src = Object.hasOwn(SOURCES, p.source) ? SOURCES[p.source]! : undefined; |
| 142 | 144 | const cols = src ? Object.keys(src.columns) : []; |
| 145 | // A page can hold several forms (one per panel), so each call gets its own ids. | |
| 146 | const n = ++fieldSeq; | |
| 147 | const sel = (name: string, label: string, options: [string, string][], now: string, hint?: string) => | |
| 148 | selectField({ label, name, ...(hint ? { hint } : {}), id: `f-${name}-${n}`, options: options.map(([value, text]) => ({ value, label: text, selected: value === now })) }); | |
| 149 | const text = (name: string, label: string, value: string, placeholder: string) => | |
| 150 | field({ label, name, value, placeholder, mono: true, maxlength: MAX_PARAM, spellcheck: false, id: `f-${name}-${n}` }); | |
| 151 | const colOpts = (first: [string, string][], pick: (c: string) => string, keep = (_c: string) => true): [string, string][] => [ | |
| 152 | ...first, | |
| 153 | ...cols.filter(keep).map((c) => [c, pick(c)] as [string, string]), | |
| 154 | ]; | |
| 143 | 155 | return html` |
| 144 | <label class="qf"><span>Chart</span> | |
| 145 | <select class="input" name="chart"> | |
| 146 | ${CHARTS.map((c) => option(c, CHART_NAMES[c], c === p.chart))} | |
| 147 | </select> | |
| 148 | </label> | |
| 149 | <label class="qf"><span>Source</span> | |
| 150 | <select class="input" name="source"> | |
| 151 | ${Object.keys(SOURCES).map((n) => option(n, n, n === p.source))} | |
| 152 | </select> | |
| 153 | </label> | |
| 154 | <label class="qf"><span>Aggregate</span> | |
| 155 | <select class="input" name="aggregate"> | |
| 156 | ${AGGREGATES.map((a) => option(a, a, a === p.aggregate))} | |
| 157 | </select> | |
| 158 | </label> | |
| 159 | <label class="qf" title="Column to aggregate"><span>Column</span> | |
| 160 | <select class="input" name="column"> | |
| 161 | ${option("", "(rows)", p.column === "")} | |
| 162 | ${cols.filter((c) => src!.columns[c] === "number").map((c) => option(c, c, c === p.column))} | |
| 163 | </select> | |
| 164 | </label> | |
| 165 | <label class="qf" title="Group by"><span>Group by</span> | |
| 166 | <select class="input" name="by"> | |
| 167 | ${(Object.keys(BUCKET_MS) as Bucket[]).map((b) => option(b, `per ${b}`, b === p.by))} | |
| 168 | ${cols.map((c) => option(c, `by ${c}`, c === p.by))} | |
| 169 | </select> | |
| 170 | </label> | |
| 171 | <label class="qf" title="Series (stacked area, heat strip, table)"><span>Series</span> | |
| 172 | <select class="input" name="series"> | |
| 173 | ${option("", "(one series)", p.series === "")} | |
| 174 | ${cols.map((c) => option(c, `split by ${c}`, c === p.series))} | |
| 175 | </select> | |
| 176 | </label> | |
| 177 | <label class="qf"><span>From</span> | |
| 178 | <input class="input mono" name="from" value="${p.from}" placeholder="from 2026-10-01" maxlength="${MAX_PARAM}" /> | |
| 179 | </label> | |
| 180 | <label class="qf"><span>To</span> | |
| 181 | <input class="input mono" name="to" value="${p.to}" placeholder="to 2026-10-08" maxlength="${MAX_PARAM}" /> | |
| 182 | </label> | |
| 183 | <label class="qf"><span>Filter column</span> | |
| 184 | <select class="input" name="fcolumn"> | |
| 185 | ${option("", "(no filter)", p.fcolumn === "")} | |
| 186 | ${cols.map((c) => option(c, c, c === p.fcolumn))} | |
| 187 | </select> | |
| 188 | </label> | |
| 189 | <label class="qf"><span>Filter operator</span> | |
| 190 | <select class="input" name="fop"> | |
| 191 | ${OPS.map((o) => option(o, o, o === p.fop))} | |
| 192 | </select> | |
| 193 | </label> | |
| 194 | <label class="qf"><span>Filter value</span> | |
| 195 | <input class="input mono" name="fvalue" value="${p.fvalue}" placeholder="value" maxlength="${MAX_PARAM}" /> | |
| 196 | </label> | |
| 197 | `; | |
| 156 | ${sel("chart", "Chart", CHARTS.map((c) => [c, CHART_NAMES[c]]), p.chart)} | |
| 157 | ${sel("source", "Source", Object.keys(SOURCES).map((s) => [s, s]), p.source)} | |
| 158 | ${sel("aggregate", "Aggregate", AGGREGATES.map((a) => [a, a]), p.aggregate)} | |
| 159 | ${sel("column", "Column", colOpts([["", "(rows)"]], (c) => c, (c) => src!.columns[c] === "number"), p.column, "Column to aggregate")} | |
| 160 | ${sel("by", "Group by", [...(Object.keys(BUCKET_MS) as Bucket[]).map((b): [string, string] => [b, `per ${b}`]), ...colOpts([], (c) => `by ${c}`)], p.by)} | |
| 161 | ${sel("series", "Series", colOpts([["", "(one series)"]], (c) => `split by ${c}`), p.series, "Stacked area, heat strip, table")} | |
| 162 | ${text("from", "From", p.from, "from 2026-10-01")} | |
| 163 | ${text("to", "To", p.to, "to 2026-10-08")} | |
| 164 | ${sel("fcolumn", "Filter column", colOpts([["", "(no filter)"]], (c) => c), p.fcolumn)} | |
| 165 | ${sel("fop", "Filter operator", OPS.map((o) => [o, o]), p.fop)} | |
| 166 | ${text("fvalue", "Filter value", p.fvalue, "value")} | |
| 167 | `; | |
| 198 | 168 | } |
| 199 | 169 | |
| 200 | 170 | /** The form's fields back from a stored query and its chart; what a form cannot say is dropped. */ |
| @@ -295,10 +265,8 @@ export async function boardsPage(ctx: Ctx): Promise<Response> { | ||
| 295 | 265 | (k) => html`<input type="hidden" name="${k}" value="${p[k as keyof Params]}" />`, |
| 296 | 266 | )} |
| 297 | 267 | <input type="hidden" name="w" value="1" /><input type="hidden" name="h" value="1" /> |
| 298 | <label class="qf"><span>Board</span><select class="input" name="slug"> | |
| 299 | ${boards.map((b) => html`<option value="${b.slug}">${b.board.title}</option>`)} | |
| 300 | </select></label> | |
| 301 | <label class="qf"><span>Panel title</span><input class="input" name="title" required maxlength="80" value="${`${p.aggregate} of ${p.source}`}" /></label> | |
| 268 | ${selectField({ label: "Board", name: "slug", id: "f-pin-slug", options: boards.map((b) => ({ value: b.slug, label: b.board.title })) })} | |
| 269 | ${field({ label: "Panel title", name: "title", id: "f-pin-title", required: true, maxlength: 80, value: `${p.aggregate} of ${p.source}` })} | |
| 302 | 270 | ${btn("Pin to board")} |
| 303 | 271 | </form>`, |
| 304 | 272 | { head: html`<h2>Pin this query</h2>` }, |
test/boards-page.test.ts +22 −4
| @@ -11,10 +11,12 @@ import { join } from "node:path"; | ||
| 11 | 11 | import { createApp } from "../src/app.ts"; |
| 12 | 12 | import { barChart, drawChart, CHARTS, gridOf, heatStrip, lineChart, numberChart, stackedArea, tableChart } from "../src/boards/chart.ts"; |
| 13 | 13 | import { createToken } from "../src/core/tokens.ts"; |
| 14 | import { createUser } from "../src/core/users.ts"; | |
| 14 | import { createUser, type User } from "../src/core/users.ts"; | |
| 15 | import { makeBoard } from "../src/actions/boards.ts"; | |
| 15 | 16 | import { serve } from "../src/http/server.ts"; |
| 16 | 17 | |
| 17 | 18 | let dir: string; |
| 19 | let admin: User; | |
| 18 | 20 | let app: ReturnType<typeof createApp>; |
| 19 | 21 | let servers: Awaited<ReturnType<typeof serve>>; |
| 20 | 22 | const tok: Record<string, string> = {}; |
| @@ -35,6 +37,7 @@ beforeAll(async () => { | ||
| 35 | 37 | return r.value; |
| 36 | 38 | }; |
| 37 | 39 | const alice = await mk("alice", true); |
| 40 | admin = alice; | |
| 38 | 41 | const bob = await mk("bob"); |
| 39 | 42 | tok.admin = createToken(app.db, alice.id, "t", "admin").token; |
| 40 | 43 | tok.bob = createToken(app.db, bob.id, "t", "admin").token; |
| @@ -63,6 +66,21 @@ const listed = async (url: string) => { | ||
| 63 | 66 | return [...t.matchAll(/<td class="mono">([a-z0-9]+)<\/td>/g)].map((m) => m[1]!).filter((x) => /^m\d$/.test(x)).sort(); |
| 64 | 67 | }; |
| 65 | 68 | |
| 69 | describe("/boards forms", () => { | |
| 70 | test("the chart and pin forms are §5 fields, not .qf", async () => { | |
| 71 | const made = await makeBoard(app, admin, "Pins"); | |
| 72 | expect(made.ok).toBe(true); | |
| 73 | const body = await (await get("/boards/data?source=runs&by=day", "admin")).text(); | |
| 74 | expect(body).not.toContain('class="qf'); | |
| 75 | for (const name of ["chart", "source", "aggregate", "column", "by", "series", "from", "to", "fcolumn", "fop", "fvalue"]) { | |
| 76 | expect(body).toMatch(new RegExp(`<div class="field"[^>]*>\\s*<label for="f-${name}-\\d+">[^]*?name="${name}"`)); | |
| 77 | } | |
| 78 | expect(body).toMatch(/<span class="hint">Column to aggregate<\/span>/); | |
| 79 | expect(body).toContain('name="slug"'); | |
| 80 | expect(body).toContain('<label for="f-pin-title">Panel title</label>'); | |
| 81 | }); | |
| 82 | }); | |
| 83 | ||
| 66 | 84 | describe("/boards/data", () => { |
| 67 | 85 | test("the result is drawn with §5's card and table, not .boards-result", async () => { |
| 68 | 86 | const body = await (await get("/boards/data?source=runs&by=day", "admin")).text(); |
| @@ -224,9 +242,9 @@ describe("every chart type from one seeded query", () => { | ||
| 224 | 242 | }); |
| 225 | 243 | test("the chart is chosen beside the query", async () => { |
| 226 | 244 | const body = await drawn("heat"); |
| 227 | expect(body).toMatch(/<select class="input" name="chart"[^>]*>/); | |
| 228 | expect(body).toContain('<option value="heat" selected>heat strip</option>'); | |
| 229 | expect(body).toContain('<option value="kind" selected>split by kind</option>'); | |
| 245 | expect(body).toMatch(/<select class="input" id="f-chart-\d+"\s+name="chart"/); | |
| 246 | expect(body).toMatch(/<option\s+value="heat"\s+selected\s*>\s*heat strip/); | |
| 247 | expect(body).toMatch(/<option\s+value="kind"\s+selected\s*>\s*split by kind/); | |
| 230 | 248 | }); |
| 231 | 249 | }); |
| 232 | 250 |
test/boards-selectors.test.ts +1 −1
| @@ -58,7 +58,7 @@ test("every query selector has a visible label and sits outside .row-actions", a | ||
| 58 | 58 | expect(form![0]).not.toContain("row-actions"); |
| 59 | 59 | expect(form![0]).toContain('class="bform"'); |
| 60 | 60 | for (const name of ["chart", "source", "aggregate", "column", "by", "series", "from", "to", "fcolumn", "fop", "fvalue"]) { |
| 61 | const m = form![1]!.match(new RegExp(`<label class="qf"[^>]*><span>([^<]+)</span>\\s*<(?:select|input)[^>]* name="${name}"`)); | |
| 61 | const m = form![1]!.match(new RegExp(`<label for="f-${name}-\\d+">([^<]+)</label>\\s*<(?:select|input)[^>]* name="${name}"`)); | |
| 62 | 62 | expect(m, name).not.toBeNull(); |
| 63 | 63 | expect(m![1]!.trim().length, name).toBeGreaterThan(0); |
| 64 | 64 | } |
test/boards-store.test.ts +1 −1
| @@ -250,7 +250,7 @@ describe("/boards", () => { | ||
| 250 | 250 | // runs in range: three days × three kinds for the heat strip; three days of bars, the series set aside |
| 251 | 251 | expect(page.match(/<rect class="cell"/g)?.length).toBe(9); |
| 252 | 252 | expect(page.match(/<rect class="bar"/g)?.length).toBe(3); |
| 253 | expect(page).toContain('<option value="heat" selected>heat strip</option>'); | |
| 253 | expect(page).toMatch(/<option\s+value="heat"\s+selected\s*>\s*heat strip/); | |
| 254 | 254 | expect(parseBoard({ ...board, panels: [{ ...board.panels[0], chart: "pie" }] }).ok).toBe(false); |
| 255 | 255 | await deleteBoard(app, alice, "charts"); |
| 256 | 256 | }); |
new above
offline
The doc comment for queryFields now sits above
let fieldSeq. Move it down to the function. A caller-supplied id prefix (panel index) would also avoid process-global mutable state and keep the HTML identical between renders.