Public: anyone can read this; the crew can change it.
Editing a config file as a form speaks the home page's language #9989
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9970-editing-a-config-file-as-a-form-speaks-t → main 3 commits fork
5 files
changed +102
−21
src/web/pages/config.ts +25 −16
| @@ -124,18 +124,21 @@ async function formPage(ctx: Ctx, scope: RepoScope, s: FormState): Promise<Respo | ||
| 124 | 124 | crumbs: scope.crumbs, |
| 125 | 125 | palette: scope.palette, |
| 126 | 126 | body: html`${head(scope, s, proposing, "Editing settings")} |
| 127 | <form method="post" action="${scope.base}/config" class="card config-form"> | |
| 128 | <div class="bar mb"> | |
| 129 | <span class="muted small">Enter in a field reviews the change.</span><span class="spacer"></span> | |
| 130 | ${review} | |
| 131 | </div> | |
| 132 | ${hidden(s, s.doc)} | |
| 133 | <input type="hidden" name="from" value="form" /> | |
| 134 | ${configFields(s.doc, { | |
| 135 | found: s.found, | |
| 136 | ...(s.errors === undefined ? {} : { errors: s.errors }), | |
| 137 | ...(s.typed === undefined ? {} : { typed: s.typed }), | |
| 138 | })} | |
| 127 | <form method="post" action="${scope.base}/config" class="config-form"> | |
| 128 | <section class="settings-block"> | |
| 129 | <div class="bar mb"> | |
| 130 | <h2>Settings</h2><span class="spacer"></span> | |
| 131 | ${review} | |
| 132 | </div> | |
| 133 | <p class="muted small">Enter in a field reviews the change.</p> | |
| 134 | ${hidden(s, s.doc)} | |
| 135 | <input type="hidden" name="from" value="form" /> | |
| 136 | ${configFields(s.doc, { | |
| 137 | found: s.found, | |
| 138 | ...(s.errors === undefined ? {} : { errors: s.errors }), | |
| 139 | ...(s.typed === undefined ? {} : { typed: s.typed }), | |
| 140 | })} | |
| 141 | </section> | |
| 139 | 142 | <div class="form-actions"> |
| 140 | 143 | ${review} |
| 141 | 144 | ${btn("Source", { href: blobHref(scope, s.ref, s.path, "?view=source"), variant: "outline" })} |
| @@ -163,10 +166,15 @@ async function reviewPage( | ||
| 163 | 166 | crumbs: scope.crumbs, |
| 164 | 167 | palette: scope.palette, |
| 165 | 168 | body: html`${head(scope, s, proposing, "Review")} |
| 166 | <pre class="card mono config-diff" aria-label="The change">${lines.map( | |
| 167 | (l) => html`<span data-op="${l.op === " " ? "same" : l.op === "+" ? "add" : "del"}">${l.op} ${l.text}</span>\n`, | |
| 168 | )}</pre> | |
| 169 | <form method="post" action="${scope.base}/config" class="card"> | |
| 169 | <section class="settings-block"> | |
| 170 | <h2>The change</h2> | |
| 171 | <pre class="mono config-diff" aria-label="The change">${lines.map( | |
| 172 | (l) => html`<span data-op="${l.op === " " ? "same" : l.op === "+" ? "add" : "del"}">${l.op} ${l.text}</span>\n`, | |
| 173 | )}</pre> | |
| 174 | </section> | |
| 175 | <form method="post" action="${scope.base}/config"> | |
| 176 | <section class="settings-block"> | |
| 177 | <h2>Commit message</h2> | |
| 170 | 178 | ${hidden(s, s.doc)} |
| 171 | 179 | ${field({ |
| 172 | 180 | name: "message", |
| @@ -183,6 +191,7 @@ async function reviewPage( | ||
| 183 | 191 | : html`You cannot push here, so this goes on a branch of your fork, with a pull request into <span class="mono">${s.ref}</span>.` |
| 184 | 192 | : html`One commit on <span class="mono">${s.ref}</span>.` |
| 185 | 193 | }</p> |
| 194 | </section> | |
| 186 | 195 | <div class="form-actions"> |
| 187 | 196 | ${btn(proposing ? "Propose change" : `Commit to ${s.ref}`, { name: "step", value: "save", variant: "primary", icon: proposing ? "fork" : "check" })} |
| 188 | 197 | ${btn("Back to the form", { name: "step", value: "edit", variant: "outline", formnovalidate: true })} |
src/web/render/config-form.ts +1 −1
| @@ -298,7 +298,7 @@ function cardList(v: Extract<Json, { t: "array" }>, p: string, title: string, o: | ||
| 298 | 298 | return html`${v.items.map((x, i) => { |
| 299 | 299 | const at = pointer(p, i); |
| 300 | 300 | const last = v.items.length - 1; |
| 301 | return html`<div class="card config-card"> | |
| 301 | return html`<div class="config-card"> | |
| 302 | 302 | <div class="bar"> |
| 303 | 303 | <strong>${title} ${i + 1}</strong><span class="spacer"></span> |
| 304 | 304 | ${when(o.readonly !== true, () => html`${i > 0 ? opBtn("Up", "up", at) : null}${i < last ? opBtn("Down", "down", at) : null}${opBtn("Remove", "remove", at, "trash")}`)} |
src/web/style/content.ts +2 −2
| @@ -1040,12 +1040,12 @@ textarea.editor:focus-visible { outline: none; } | ||
| 1040 | 1040 | arrays as a wrapping row of chips, object arrays as cards. */ |
| 1041 | 1041 | .config-group { border-left: 2px solid var(--border); padding-left: 12px; } |
| 1042 | 1042 | .config-group > legend { font-weight: 600; margin-bottom: 8px; } |
| 1043 | .config-card { padding: 12px; margin: 0 0 12px; } | |
| 1043 | .config-card { border-top: 1px solid var(--border); padding: 12px 0; margin: 0; } | |
| 1044 | 1044 | .config-chips { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 8px; } |
| 1045 | 1045 | .config-chips > li { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; } |
| 1046 | 1046 | .config-chips .input { width: auto; min-width: 8rem; } |
| 1047 | 1047 | .config-chips .field { margin: 0; display: flex; align-items: center; gap: 4px; flex-wrap: wrap; } |
| 1048 | .config-diff { overflow-x: auto; white-space: pre; padding: 12px; } | |
| 1048 | .config-diff { overflow-x: auto; white-space: pre; margin: 0; } | |
| 1049 | 1049 | .config-diff > [data-op="add"] { background: var(--add-bg); color: var(--add-fg); } |
| 1050 | 1050 | .config-diff > [data-op="del"] { background: var(--del-bg); color: var(--del-fg); } |
| 1051 | 1051 | /* Reduced motion: the pulses hold still at a legible level, the scroll-tied |
test/config-form.test.ts +2 −2
| @@ -74,7 +74,7 @@ describe("fields inferred from the values", () => { | ||
| 74 | 74 | expect(html).toMatch(/<fieldset class="field config-group"[^>]*>\s*<legend>db<\/legend>/); |
| 75 | 75 | expect(html).toContain('class="config-chips"'); |
| 76 | 76 | expect(html).toContain('name="v/tags/1"'); |
| 77 | expect(html.match(/class="card config-card"/g)?.length).toBe(2); | |
| 77 | expect(html.match(/class="config-card"/g)?.length).toBe(2); | |
| 78 | 78 | expect(html).toContain('name="v/servers/1/host"'); |
| 79 | 79 | }); |
| 80 | 80 | |
| @@ -318,7 +318,7 @@ describe("the config form on the web", () => { | ||
| 318 | 318 | const form = await (await get("/alice/lamp/config/main/deploy.yml", alice)).text(); |
| 319 | 319 | expect(form).toContain('name="v/replicas"'); |
| 320 | 320 | // A plain form post: nothing here needs a script. |
| 321 | expect(form).toContain('<form method="post" action="/alice/lamp/config" class="card config-form">'); | |
| 321 | expect(form).toContain('<form method="post" action="/alice/lamp/config" class="config-form">'); | |
| 322 | 322 | const before = await commitCount("alice", "lamp"); |
| 323 | 323 | const res = await post("/alice/lamp/config", { |
| 324 | 324 | ref: "main", |
test/config-language.test.ts added +72
| @@ -0,0 +1,72 @@ | ||
| 1 | /** Editing a config file as a form speaks the home page's language: sections and rows, no card (#9970). */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp, type App } from "../src/app.ts"; | |
| 7 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 8 | import { createSession } from "../src/core/sessions.ts"; | |
| 9 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 10 | import { digestOf } from "../src/web/pages/config.ts"; | |
| 11 | import { seedAdmin } from "./helpers/admin.ts"; | |
| 12 | import { quietLogs } from "./helpers/globals.ts"; | |
| 13 | ||
| 14 | quietLogs(); | |
| 15 | ||
| 16 | const SETTINGS = '{\n "name": "lamp",\n "servers": [\n { "host": "a" },\n { "host": "b" }\n ]\n}\n'; | |
| 17 | let dataDir = ""; | |
| 18 | let app: App, servers: Servers; | |
| 19 | let alice = ""; | |
| 20 | ||
| 21 | beforeAll(async () => { | |
| 22 | dataDir = await mkdtemp(join(tmpdir(), "forge-config-lang-")); | |
| 23 | app = createApp({ dataDir, port: 0 }); | |
| 24 | servers = await serve(app); | |
| 25 | const a = await seedAdmin(app, { username: "alice", email: "[email protected]", password: "correct-horse-battery" }); | |
| 26 | if (!a.ok) throw new Error(a.error); | |
| 27 | alice = `forge_session=${createSession(app.db, a.value.id).token}`; | |
| 28 | const made = await createRepo(app, a.value, { name: "lamp", visibility: "public" }); | |
| 29 | if (!made.ok) throw new Error("repo failed"); | |
| 30 | await commitToBranch(app, a.value, made.value, { message: "first", files: [{ path: "settings.json", content: SETTINGS }] }); | |
| 31 | }); | |
| 32 | afterAll(async () => { | |
| 33 | servers.stop(); | |
| 34 | app.close(); | |
| 35 | await rm(dataDir, { recursive: true, force: true }); | |
| 36 | }); | |
| 37 | ||
| 38 | const main = (page: string) => page.slice(page.indexOf("<main"), page.indexOf("</main>")); | |
| 39 | const post = (fields: Record<string, string>) => | |
| 40 | fetch(`${servers.url}/alice/lamp/config`, { | |
| 41 | method: "POST", | |
| 42 | headers: { origin: new URL(servers.url).origin, cookie: alice }, | |
| 43 | body: new URLSearchParams(fields), | |
| 44 | redirect: "manual", | |
| 45 | }); | |
| 46 | const doc = '{"name":"lamp","servers":[{"host":"a"},{"host":"b"}]}'; | |
| 47 | const base = { ref: "main", path: "settings.json", base: digestOf(SETTINGS), doc }; | |
| 48 | ||
| 49 | test("the form is a settings-block, list items are rows, no card", async () => { | |
| 50 | const m = main(await (await fetch(`${servers.url}/alice/lamp/config/main/settings.json`, { headers: { cookie: alice } })).text()); | |
| 51 | expect(m).not.toContain('class="card'); | |
| 52 | expect(m).toMatch(/<section class="settings-block">\s*<div class="bar mb">\s*<h2>Settings<\/h2>/); | |
| 53 | expect(m).toContain('class="config-card"'); | |
| 54 | }); | |
| 55 | ||
| 56 | test("the form coming back after an op has no card", async () => { | |
| 57 | const res = await post({ ...base, from: "form", op: JSON.stringify(["add", "/servers"]) }); | |
| 58 | const m = main(await res.text()); | |
| 59 | expect(m).toContain('class="config-form"'); | |
| 60 | expect(m.match(/class="config-card"/g)?.length).toBe(3); | |
| 61 | expect(m).not.toContain('class="card'); | |
| 62 | }); | |
| 63 | ||
| 64 | test("the review step is two settings-blocks, the diff without a box", async () => { | |
| 65 | const res = await post({ ...base, step: "review", doc: doc.replace('"lamp"', '"lamp2"') }); | |
| 66 | const m = main(await res.text()); | |
| 67 | expect(m).not.toContain('class="card'); | |
| 68 | expect(m).toMatch(/<h2>The change<\/h2>/); | |
| 69 | expect(m).toMatch(/<h2>Commit message<\/h2>/); | |
| 70 | expect(m).toContain('class="mono config-diff"'); | |
| 71 | expect(m).toContain('value="save"'); | |
| 72 | }); |