Public: anyone can read this; the crew can change it.
Boards: the query selectors are visible and usable #2668
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2666-boards-the-query-selectors-are-visible-a → main 1 commit fork
4 files
changed +161
−51
src/web/pages/boards-view.ts +15 −15
| @@ -70,8 +70,8 @@ const denied = (ctx: Ctx) => { | ||
| 70 | 70 | export async function boardsList(ctx: Ctx): Promise<Response> { |
| 71 | 71 | const listed = await listAllBoards(ctx.app, ctx.auth.user); |
| 72 | 72 | if (!listed.ok) return notFoundPage(ctx); |
| 73 | const make = html`<form method="post" action="/boards/new" class="row-actions"> | |
| 74 | <input class="input" name="title" required maxlength="${String(MAX_TITLE)}" placeholder="New board's title" aria-label="Title of the new board" /> | |
| 73 | const make = html`<form method="post" action="/boards/new" class="bform"> | |
| 74 | <label class="qf"><span>New board</span><input class="input" name="title" required maxlength="${String(MAX_TITLE)}" placeholder="Title" /></label> | |
| 75 | 75 | ${btn("Make board", { variant: "primary" })} |
| 76 | 76 | </form>`; |
| 77 | 77 | const body = |
| @@ -98,15 +98,15 @@ export async function boardsList(ctx: Ctx): Promise<Response> { | ||
| 98 | 98 | } |
| 99 | 99 | |
| 100 | 100 | function panelForm(slug: string, op: string, p: Panel | undefined, index: number, fields: Html): Html { |
| 101 | return html`<form method="post" action="/boards/${slug}/${op}" class="row-actions"> | |
| 101 | return html`<form method="post" action="/boards/${slug}/${op}" class="bform"> | |
| 102 | 102 | ${op === "edit" ? html`<input type="hidden" name="index" value="${String(index)}" />` : null} |
| 103 | <input class="input" name="title" required maxlength="${String(MAX_TITLE)}" value="${p?.title ?? ""}" placeholder="Panel title" aria-label="Panel title" /> | |
| 104 | <select class="input" name="w" aria-label="Width in columns"> | |
| 103 | <label class="qf"><span>Panel title</span><input class="input" name="title" required maxlength="${String(MAX_TITLE)}" value="${p?.title ?? ""}" placeholder="Panel title" /></label> | |
| 104 | <label class="qf"><span>Width</span><select class="input" name="w"> | |
| 105 | 105 | ${[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>`)} |
| 106 | </select> | |
| 107 | <select class="input" name="h" aria-label="Height"> | |
| 106 | </select></label> | |
| 107 | <label class="qf"><span>Height</span><select class="input" name="h"> | |
| 108 | 108 | ${[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>`)} |
| 109 | </select> | |
| 109 | </select></label> | |
| 110 | 110 | ${fields} |
| 111 | 111 | ${btn(op === "edit" ? "Save panel" : "Add panel", { variant: "primary" })} |
| 112 | 112 | </form>`; |
| @@ -137,17 +137,17 @@ function manage(slug: string, board: Board, adding: Html): Html { | ||
| 137 | 137 | () => card(panelForm(slug, "add", undefined, 0, adding), { head: html`<h2>Add a panel</h2>` }), |
| 138 | 138 | )} |
| 139 | 139 | ${card( |
| 140 | html`<form method="post" action="/boards/${slug}/rename" class="row-actions"> | |
| 141 | <input class="input" name="title" required maxlength="${String(MAX_TITLE)}" value="${board.title}" aria-label="Board title" /> | |
| 140 | html`<form method="post" action="/boards/${slug}/rename" class="bform"> | |
| 141 | <label class="qf"><span>Board title</span><input class="input" name="title" required maxlength="${String(MAX_TITLE)}" value="${board.title}" /></label> | |
| 142 | 142 | ${btn("Rename")} |
| 143 | 143 | </form> |
| 144 | <form method="post" action="/boards/${slug}/settings" class="row-actions"> | |
| 145 | <select class="input" name="range" aria-label="Range">${RANGES.map((r) => option(r, r === board.range))}</select> | |
| 146 | <select class="input" name="refresh" aria-label="Refresh">${REFRESHES.map((r) => option(r, r === board.refresh))}</select> | |
| 144 | <form method="post" action="/boards/${slug}/settings" class="bform"> | |
| 145 | <label class="qf"><span>Range</span><select class="input" name="range">${RANGES.map((r) => option(r, r === board.range))}</select></label> | |
| 146 | <label class="qf"><span>Refresh</span><select class="input" name="refresh">${REFRESHES.map((r) => option(r, r === board.refresh))}</select></label> | |
| 147 | 147 | ${btn("Save settings")} |
| 148 | 148 | </form> |
| 149 | <form method="post" action="/boards/${slug}/duplicate" class="row-actions">${btn("Duplicate")}</form> | |
| 150 | <form method="post" action="/boards/${slug}/delete" class="row-actions">${btn("Delete this board", { variant: "danger" })}</form>`, | |
| 149 | <form method="post" action="/boards/${slug}/duplicate" class="bform">${btn("Duplicate")}</form> | |
| 150 | <form method="post" action="/boards/${slug}/delete" class="bform">${btn("Delete this board", { variant: "danger" })}</form>`, | |
| 151 | 151 | { head: html`<h2>Board</h2>` }, |
| 152 | 152 | )}`; |
| 153 | 153 | } |
src/web/pages/boards.ts +58 −36
| @@ -155,37 +155,59 @@ export function queryFields(p: Params) { | ||
| 155 | 155 | const src = Object.hasOwn(SOURCES, p.source) ? SOURCES[p.source]! : undefined; |
| 156 | 156 | const cols = src ? Object.keys(src.columns) : []; |
| 157 | 157 | return html` |
| 158 | <select class="input" name="chart" aria-label="Chart"> | |
| 159 | ${CHARTS.map((c) => option(c, CHART_NAMES[c], c === p.chart))} | |
| 160 | </select> | |
| 161 | <select class="input" name="source" aria-label="Source"> | |
| 162 | ${Object.keys(SOURCES).map((n) => option(n, n, n === p.source))} | |
| 163 | </select> | |
| 164 | <select class="input" name="aggregate" aria-label="Aggregate"> | |
| 165 | ${AGGREGATES.map((a) => option(a, a, a === p.aggregate))} | |
| 166 | </select> | |
| 167 | <select class="input" name="column" aria-label="Column to aggregate"> | |
| 168 | ${option("", "(rows)", p.column === "")} | |
| 169 | ${cols.filter((c) => src!.columns[c] === "number").map((c) => option(c, c, c === p.column))} | |
| 170 | </select> | |
| 171 | <select class="input" name="by" aria-label="Group by"> | |
| 172 | ${(Object.keys(BUCKET_MS) as Bucket[]).map((b) => option(b, `per ${b}`, b === p.by))} | |
| 173 | ${cols.map((c) => option(c, `by ${c}`, c === p.by))} | |
| 174 | </select> | |
| 175 | <select class="input" name="series" aria-label="Series (stacked area, heat strip, table)"> | |
| 176 | ${option("", "(one series)", p.series === "")} | |
| 177 | ${cols.map((c) => option(c, `split by ${c}`, c === p.series))} | |
| 178 | </select> | |
| 179 | <input class="input mono" name="from" value="${p.from}" placeholder="from 2026-10-01" aria-label="From" maxlength="${MAX_PARAM}" /> | |
| 180 | <input class="input mono" name="to" value="${p.to}" placeholder="to 2026-10-08" aria-label="To" maxlength="${MAX_PARAM}" /> | |
| 181 | <select class="input" name="fcolumn" aria-label="Filter column"> | |
| 182 | ${option("", "(no filter)", p.fcolumn === "")} | |
| 183 | ${cols.map((c) => option(c, c, c === p.fcolumn))} | |
| 184 | </select> | |
| 185 | <select class="input" name="fop" aria-label="Filter operator"> | |
| 186 | ${OPS.map((o) => option(o, o, o === p.fop))} | |
| 187 | </select> | |
| 188 | <input class="input mono" name="fvalue" value="${p.fvalue}" placeholder="value" aria-label="Filter value" maxlength="${MAX_PARAM}" /> | |
| 158 | <label class="qf"><span>Chart</span> | |
| 159 | <select class="input" name="chart"> | |
| 160 | ${CHARTS.map((c) => option(c, CHART_NAMES[c], c === p.chart))} | |
| 161 | </select> | |
| 162 | </label> | |
| 163 | <label class="qf"><span>Source</span> | |
| 164 | <select class="input" name="source"> | |
| 165 | ${Object.keys(SOURCES).map((n) => option(n, n, n === p.source))} | |
| 166 | </select> | |
| 167 | </label> | |
| 168 | <label class="qf"><span>Aggregate</span> | |
| 169 | <select class="input" name="aggregate"> | |
| 170 | ${AGGREGATES.map((a) => option(a, a, a === p.aggregate))} | |
| 171 | </select> | |
| 172 | </label> | |
| 173 | <label class="qf" title="Column to aggregate"><span>Column</span> | |
| 174 | <select class="input" name="column"> | |
| 175 | ${option("", "(rows)", p.column === "")} | |
| 176 | ${cols.filter((c) => src!.columns[c] === "number").map((c) => option(c, c, c === p.column))} | |
| 177 | </select> | |
| 178 | </label> | |
| 179 | <label class="qf" title="Group by"><span>Group by</span> | |
| 180 | <select class="input" name="by"> | |
| 181 | ${(Object.keys(BUCKET_MS) as Bucket[]).map((b) => option(b, `per ${b}`, b === p.by))} | |
| 182 | ${cols.map((c) => option(c, `by ${c}`, c === p.by))} | |
| 183 | </select> | |
| 184 | </label> | |
| 185 | <label class="qf" title="Series (stacked area, heat strip, table)"><span>Series</span> | |
| 186 | <select class="input" name="series"> | |
| 187 | ${option("", "(one series)", p.series === "")} | |
| 188 | ${cols.map((c) => option(c, `split by ${c}`, c === p.series))} | |
| 189 | </select> | |
| 190 | </label> | |
| 191 | <label class="qf"><span>From</span> | |
| 192 | <input class="input mono" name="from" value="${p.from}" placeholder="from 2026-10-01" maxlength="${MAX_PARAM}" /> | |
| 193 | </label> | |
| 194 | <label class="qf"><span>To</span> | |
| 195 | <input class="input mono" name="to" value="${p.to}" placeholder="to 2026-10-08" maxlength="${MAX_PARAM}" /> | |
| 196 | </label> | |
| 197 | <label class="qf"><span>Filter column</span> | |
| 198 | <select class="input" name="fcolumn"> | |
| 199 | ${option("", "(no filter)", p.fcolumn === "")} | |
| 200 | ${cols.map((c) => option(c, c, c === p.fcolumn))} | |
| 201 | </select> | |
| 202 | </label> | |
| 203 | <label class="qf"><span>Filter operator</span> | |
| 204 | <select class="input" name="fop"> | |
| 205 | ${OPS.map((o) => option(o, o, o === p.fop))} | |
| 206 | </select> | |
| 207 | </label> | |
| 208 | <label class="qf"><span>Filter value</span> | |
| 209 | <input class="input mono" name="fvalue" value="${p.fvalue}" placeholder="value" maxlength="${MAX_PARAM}" /> | |
| 210 | </label> | |
| 189 | 211 | `; |
| 190 | 212 | } |
| 191 | 213 | |
| @@ -234,7 +256,7 @@ export async function boardsPage(ctx: Ctx): Promise<Response> { | ||
| 234 | 256 | ); |
| 235 | 257 | |
| 236 | 258 | const form = card( |
| 237 | html`<form method="get" action="/boards/data" class="row-actions"> | |
| 259 | html`<form method="get" action="/boards/data" class="bform"> | |
| 238 | 260 | ${queryFields(p)} |
| 239 | 261 | ${btn("Draw", { variant: "primary" })} |
| 240 | 262 | </form>`, |
| @@ -275,15 +297,15 @@ export async function boardsPage(ctx: Ctx): Promise<Response> { | ||
| 275 | 297 | boards.length === 0 || !answer.ok |
| 276 | 298 | ? null |
| 277 | 299 | : card( |
| 278 | html`<form method="post" action="/boards/pin" class="row-actions"> | |
| 300 | html`<form method="post" action="/boards/pin" class="bform"> | |
| 279 | 301 | ${PANEL_FIELDS.map( |
| 280 | 302 | (k) => html`<input type="hidden" name="${k}" value="${p[k as keyof Params]}" />`, |
| 281 | 303 | )} |
| 282 | 304 | <input type="hidden" name="w" value="1" /><input type="hidden" name="h" value="1" /> |
| 283 | <select class="input" name="slug" aria-label="Board"> | |
| 305 | <label class="qf"><span>Board</span><select class="input" name="slug"> | |
| 284 | 306 | ${boards.map((b) => html`<option value="${b.slug}">${b.board.title}</option>`)} |
| 285 | </select> | |
| 286 | <input class="input" name="title" required maxlength="80" value="${`${p.aggregate} of ${p.source}`}" aria-label="Panel title" /> | |
| 307 | </select></label> | |
| 308 | <label class="qf"><span>Panel title</span><input class="input" name="title" required maxlength="80" value="${`${p.aggregate} of ${p.source}`}" /></label> | |
| 287 | 309 | ${btn("Pin to board")} |
| 288 | 310 | </form>`, |
| 289 | 311 | { head: html`<h2>Pin this query</h2>` }, |
src/web/style/content.ts +4
| @@ -401,6 +401,10 @@ a.origin-chip:hover { border-color: var(--border-strong); color: var(--fg); } | ||
| 401 | 401 | .chart-box.is-table { aspect-ratio: auto; height: 260px; overflow: auto; } |
| 402 | 402 | .bp-h2 .chart-box.is-table { height: 400px; } |
| 403 | 403 | .bp-h3 .chart-box.is-table { height: 540px; } |
| 404 | .bform { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 10px 12px; margin-bottom: 8px; } | |
| 405 | .bform .qf { display: flex; flex-direction: column; gap: 4px; flex: 1 1 140px; min-width: 0; } | |
| 406 | .bform .qf > span { font-size: 12px; font-weight: 600; color: var(--fg); } | |
| 407 | .bform .btn { flex: none; } | |
| 404 | 408 | .boards-result .table { margin-top: 12px; } |
| 405 | 409 | .sys-pair { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 16px; margin-bottom: 16px; } |
| 406 | 410 | .sys-pair .chart-box { max-width: none; } |
test/boards-selectors.test.ts added +84
| @@ -0,0 +1,84 @@ | ||
| 1 | /** | |
| 2 | * /boards/data and a board's forms: every selector is in the page with a | |
| 3 | * visible label, none sits in the hover-only `.row-actions` (opacity 0 until | |
| 4 | * a `.row` is hovered, which hid them), and each theme's label, field and | |
| 5 | * border colours are a readable pair. | |
| 6 | */ | |
| 7 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 8 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { createApp } from "../src/app.ts"; | |
| 12 | import { createToken } from "../src/core/tokens.ts"; | |
| 13 | import { createUser } from "../src/core/users.ts"; | |
| 14 | import { THEMES } from "../src/design/themes.ts"; | |
| 15 | import { serve } from "../src/http/server.ts"; | |
| 16 | import { content } from "../src/web/style/content.ts"; | |
| 17 | import { components } from "../src/web/style/components.ts"; | |
| 18 | ||
| 19 | let dir: string; | |
| 20 | let app: ReturnType<typeof createApp>; | |
| 21 | let servers: Awaited<ReturnType<typeof serve>>; | |
| 22 | let token: string; | |
| 23 | ||
| 24 | beforeAll(async () => { | |
| 25 | dir = await mkdtemp(join(tmpdir(), "forge-boards-selectors-")); | |
| 26 | app = createApp({ dataDir: dir, port: 0 }); | |
| 27 | servers = await serve(app); | |
| 28 | const r = await createUser(app.db, { | |
| 29 | origin: "seed", trusted: true, username: "alice", email: "[email protected]", | |
| 30 | password: "a-good-passphrase", adminIfFirst: true, | |
| 31 | }); | |
| 32 | if (!r.ok) throw new Error(r.error); | |
| 33 | token = createToken(app.db, r.value.id, "t", "admin").token; | |
| 34 | }); | |
| 35 | ||
| 36 | afterAll(async () => { | |
| 37 | servers.stop(); | |
| 38 | app.close(); | |
| 39 | await rm(dir, { recursive: true, force: true }); | |
| 40 | }); | |
| 41 | ||
| 42 | const lum = (hex: string) => { | |
| 43 | const c = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255) | |
| 44 | .map((v) => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4)); | |
| 45 | return 0.2126 * c[0]! + 0.7152 * c[1]! + 0.0722 * c[2]!; | |
| 46 | }; | |
| 47 | const ratio = (a: string, b: string) => { | |
| 48 | const [hi, lo] = [lum(a), lum(b)].sort((x, y) => y - x); | |
| 49 | return (hi! + 0.05) / (lo! + 0.05); | |
| 50 | }; | |
| 51 | ||
| 52 | test("every query selector has a visible label and sits outside .row-actions", async () => { | |
| 53 | const res = await fetch(`${servers.url}/boards/data`, { headers: { authorization: `Bearer ${token}` } }); | |
| 54 | expect(res.status).toBe(200); | |
| 55 | const html = await res.text(); | |
| 56 | const form = html.match(/<form method="get" action="\/boards\/data"[^>]*>([\s\S]*?)<\/form>/); | |
| 57 | expect(form).not.toBeNull(); | |
| 58 | expect(form![0]).not.toContain("row-actions"); | |
| 59 | expect(form![0]).toContain('class="bform"'); | |
| 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}"`)); | |
| 62 | expect(m, name).not.toBeNull(); | |
| 63 | expect(m![1]!.trim().length, name).toBeGreaterThan(0); | |
| 64 | } | |
| 65 | }); | |
| 66 | ||
| 67 | test("the selector styles never hide or collapse a control", () => { | |
| 68 | const css = components + content; | |
| 69 | const rules = [...css.matchAll(/(^|\})\s*([^{}]*\.(?:bform|qf)[^{}]*)\{([^}]*)\}/g)].map((m) => m[3]!); | |
| 70 | expect(rules.length).toBeGreaterThan(0); | |
| 71 | for (const body of rules) { | |
| 72 | expect(body).not.toMatch(/opacity:\s*0\b|display:\s*none|visibility:\s*hidden|height:\s*0\b/); | |
| 73 | } | |
| 74 | }); | |
| 75 | ||
| 76 | test("in every theme the label, field text and border read against their ground", () => { | |
| 77 | for (const [name, theme] of Object.entries(THEMES)) { | |
| 78 | const t = theme.tokens; | |
| 79 | expect(ratio(t.fg, t.bg), `${name} label`).toBeGreaterThanOrEqual(4.5); | |
| 80 | expect(ratio(t.fg, t.card), `${name} field text`).toBeGreaterThanOrEqual(4.5); | |
| 81 | expect(ratio(t["border-strong"], t.card), `${name} border`).toBeGreaterThan(1.3); | |
| 82 | expect(t.card.toLowerCase(), `${name} field ground`).not.toBe(t.fg.toLowerCase()); | |
| 83 | } | |
| 84 | }); |
new above
offline