Public: anyone can read this; the crew can change it.

Editing a config file as a form speaks the home page's language #9989

Merged opened this

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

Merged

scout/forge:scout/9970-editing-a-config-file-as-a-form-speaks-t → main 3 commits fork

  • Merged. scout/9970-editing-a-config-file-as-a-form-speaks-t was merged into main as 623a4f3.

Review

Sign in to review this change.
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
124124 crumbs: scope.crumbs,
125125 palette: scope.palette,
126126 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>
139142 <div class="form-actions">
140143 ${review}
141144 ${btn("Source", { href: blobHref(scope, s.ref, s.path, "?view=source"), variant: "outline" })}
@@ -163,10 +166,15 @@ async function reviewPage(
163166 crumbs: scope.crumbs,
164167 palette: scope.palette,
165168 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>
170178 ${hidden(s, s.doc)}
171179 ${field({
172180 name: "message",
@@ -183,6 +191,7 @@ async function reviewPage(
183191 : 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>.`
184192 : html`One commit on <span class="mono">${s.ref}</span>.`
185193 }</p>
194 </section>
186195 <div class="form-actions">
187196 ${btn(proposing ? "Propose change" : `Commit to ${s.ref}`, { name: "step", value: "save", variant: "primary", icon: proposing ? "fork" : "check" })}
188197 ${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:
298298 return html`${v.items.map((x, i) => {
299299 const at = pointer(p, i);
300300 const last = v.items.length - 1;
301 return html`<div class="card config-card">
301 return html`<div class="config-card">
302302 <div class="bar">
303303 <strong>${title} ${i + 1}</strong><span class="spacer"></span>
304304 ${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; }
10401040 arrays as a wrapping row of chips, object arrays as cards. */
10411041 .config-group { border-left: 2px solid var(--border); padding-left: 12px; }
10421042 .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; }
10441044 .config-chips { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 8px; }
10451045 .config-chips > li { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; }
10461046 .config-chips .input { width: auto; min-width: 8rem; }
10471047 .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; }
10491049 .config-diff > [data-op="add"] { background: var(--add-bg); color: var(--add-fg); }
10501050 .config-diff > [data-op="del"] { background: var(--del-bg); color: var(--del-fg); }
10511051 /* 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", () => {
7474 expect(html).toMatch(/<fieldset class="field config-group"[^>]*>\s*<legend>db<\/legend>/);
7575 expect(html).toContain('class="config-chips"');
7676 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);
7878 expect(html).toContain('name="v/servers/1/host"');
7979 });
8080
@@ -318,7 +318,7 @@ describe("the config form on the web", () => {
318318 const form = await (await get("/alice/lamp/config/main/deploy.yml", alice)).text();
319319 expect(form).toContain('name="v/replicas"');
320320 // 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">');
322322 const before = await commitCount("alice", "lamp");
323323 const res = await post("/alice/lamp/config", {
324324 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs