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

Merged opened this

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

Merged

scout/forge:scout/4689-the-boards-chart-and-pin-forms-wrap-each → main 2 commits fork 1 thread · 1 unresolved

  • Merged. scout/4689-the-boards-chart-and-pin-forms-wrap-each was merged into main as 0f3e91d.

Review

Sign in to review this change.
4 files changed +51 −65
src/web/pages/boards.ts +27 −59
@@ -18,7 +18,7 @@ import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../cor
1818 import { notFoundPage } from "../chrome.ts";
1919 import { html, raw, when } from "../html.ts";
2020 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";
2222
2323 const BUCKET_MS = { minute: 60_000, hour: 3_600_000, day: 86_400_000 } as const;
2424 const AGGREGATES = ["count", "sum", "avg", "max"] as const;
@@ -137,64 +137,34 @@ const option = (value: string, label: string, selected: boolean) =>
137137 html`<option value="${value}" ${selected ? raw("selected") : null}>${label}</option>`;
138138
139139 /** The inputs that say a query, filled from `p`; the page's form and a panel's share them. */
140 let fieldSeq = 0;

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.

141
140142 export function queryFields(p: Params) {
141143 const src = Object.hasOwn(SOURCES, p.source) ? SOURCES[p.source]! : undefined;
142144 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 ];
143155 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 `;
198168 }
199169
200170 /** 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> {
295265 (k) => html`<input type="hidden" name="${k}" value="${p[k as keyof Params]}" />`,
296266 )}
297267 <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}` })}
302270 ${btn("Pin to board")}
303271 </form>`,
304272 { head: html`<h2>Pin this query</h2>` },
test/boards-page.test.ts +22 −4
@@ -11,10 +11,12 @@ import { join } from "node:path";
1111 import { createApp } from "../src/app.ts";
1212 import { barChart, drawChart, CHARTS, gridOf, heatStrip, lineChart, numberChart, stackedArea, tableChart } from "../src/boards/chart.ts";
1313 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";
1516 import { serve } from "../src/http/server.ts";
1617
1718 let dir: string;
19 let admin: User;
1820 let app: ReturnType<typeof createApp>;
1921 let servers: Awaited<ReturnType<typeof serve>>;
2022 const tok: Record<string, string> = {};
@@ -35,6 +37,7 @@ beforeAll(async () => {
3537 return r.value;
3638 };
3739 const alice = await mk("alice", true);
40 admin = alice;
3841 const bob = await mk("bob");
3942 tok.admin = createToken(app.db, alice.id, "t", "admin").token;
4043 tok.bob = createToken(app.db, bob.id, "t", "admin").token;
@@ -63,6 +66,21 @@ const listed = async (url: string) => {
6366 return [...t.matchAll(/<td class="mono">([a-z0-9]+)<\/td>/g)].map((m) => m[1]!).filter((x) => /^m\d$/.test(x)).sort();
6467 };
6568
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
6684 describe("/boards/data", () => {
6785 test("the result is drawn with §5's card and table, not .boards-result", async () => {
6886 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", () => {
224242 });
225243 test("the chart is chosen beside the query", async () => {
226244 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/);
230248 });
231249 });
232250
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
5858 expect(form![0]).not.toContain("row-actions");
5959 expect(form![0]).toContain('class="bform"');
6060 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}"`));
6262 expect(m, name).not.toBeNull();
6363 expect(m![1]!.trim().length, name).toBeGreaterThan(0);
6464 }
test/boards-store.test.ts +1 −1
@@ -250,7 +250,7 @@ describe("/boards", () => {
250250 // runs in range: three days × three kinds for the heat strip; three days of bars, the series set aside
251251 expect(page.match(/<rect class="cell"/g)?.length).toBe(9);
252252 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/);
254254 expect(parseBoard({ ...board, panels: [{ ...board.panels[0], chart: "pie" }] }).ok).toBe(false);
255255 await deleteBoard(app, alice, "charts");
256256 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs