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

Settings › Trusted speaks the home page's language #9796

Merged opened this

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

Merged

mason/forge:mason/9792-settings-trusted-speaks-the-home-page-s → main 3 commits fork 3 threads · 3 unresolved

  • Merged. mason/9792-settings-trusted-speaks-the-home-page-s was merged into main as 7731c5e.

Review

Sign in to review this change.
3 files changed +140 −23
docs/design/areas/settings.md +8
@@ -94,3 +94,11 @@ Settings › Accounts (`src/web/pages/accounts.ts`) adds no component class. As
9494 accounts are `row()`s in `<ul class="list setting-list" id="accounts-list">` under `<h2>Who is on this forge</h2>`, the acts
9595 (Sign-ins, Verify, Suspend, Remove) in the row's `after` slot; the mail, ownerless-organization, sign-in reach and
9696 confirm/refusal notices are plain `<section class="settings-block">` blocks with an `<h2>`. `test/accounts-rows.test.ts` holds the shape.
97
98 Settings › Trusted (`trustedPage()` in `src/web/pages/trusted.ts`, #9792) adds no component class. As on the home page it is
99 headings over rows: Fold-in, Trusted sources and Trust a source are each a `<section class="settings-block">` with an
100 `<h2>`. Each trusted entry is a `row()` in `<ul class="list setting-list" id="trusted-list">`; its state is a badge in the
101 title, each with a glyph (Trusted with a check, Ends soon with a clock, Lapsed with a ban), never colour alone; the row's
102 `data-state` is `open`, or `draft` once lapsed, and Remove and Revert landings sit in its trail as
103 `.row-fields` forms. With no entry, one plain sentence: no one is trusted, so every pull waits for review. The forms post to the same routes as
104 before. `test/trusted-sources.test.ts` holds the shape.
src/web/pages/trusted.ts +30 −23
@@ -17,7 +17,7 @@ import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
1717 import { field, readForm } from "../form.ts";
1818 import { html } from "../html.ts";
1919 import { flashTo, page } from "../layout.ts";
20 import { badge, btn, card, icon, row, time } from "../ui.ts";
20 import { badge, btn, icon, row, time } from "../ui.ts";
2121 import { accountSections } from "./origins.ts";
2222
2323 function owner(ctx: Ctx): Response | undefined {
@@ -42,8 +42,9 @@ export function trustedPage(ctx: Ctx): Response {
4242 const now = new Date();
4343 const stopped = foldInStopped(ctx.app.db);
4444 const entries = listTrusted(ctx.app.db);
45 const body = html`${card(
46 html`<div class="card-body"><p class="muted small">
45 const body = html`<section class="settings-block">
46 <h2>Fold-in</h2>
47 <p class="muted small">
4748 Work from a trusted source flows in as it appears and lands by true merge when its checks pass,
4849 with no person's review. It never skips the check, the boot proof or the walls: a pull touching
4950 secrets, credentials, permissions, the contract or release machinery still waits for you. Anyone
@@ -53,25 +54,32 @@ export function trustedPage(ctx: Ctx): Response {
5354 <form method="post" action="/settings/trusted/stop" class="row-fields">
5455 <input type="hidden" name="stop" value="${stopped ? "0" : "1"}" />
5556 ${stopped
56 ? html`${badge("Folding in is stopped", { variant: "outline" })}
57 ? html`${badge("Folding in is stopped", { variant: "outline", icon: "lock" })}
5758 <span class="muted small">by ${stopped.by}, ${time(stopped.at, { date: true })}</span>
5859 ${btn("Resume folding in", { size: "sm" })}`
5960 : btn("Stop all folding in", { size: "sm", variant: "outline" })}
60 </form></div>`,
61 { head: html`${icon("lock")}<h2>Fold-in</h2>` },
62 )}
63 ${card(
64 html`<ul class="list setting-list">
65 ${entries.length === 0
66 ? html`<li class="muted small">No one is trusted. Everyone waits for review.</li>`
67 : entries.map((s) => {
61 </form>
62 </section>
63 <section class="settings-block">
64 <h2>Trusted sources</h2>
65 ${entries.length === 0
66 ? html`<p class="muted small">No one is trusted, so every pull waits for review.</p>`
outdated

Principles 1 and 8, box 1: this is two sentences and restates the Fold-in paragraph (lands when checks pass, no review). Make it one plain sentence, e.g. "No one is trusted, so every pull waits for review." Drop the second sentence.

67 : html`<ul class="list setting-list" id="trusted-list">
68 ${entries.map((s) => {
6869 const lapsed = s.until <= now.toISOString();
70 const soon = !lapsed && endsSoon(s, now);
6971 return row({
7072 lead: icon("user"),
71 title: html`${s.account} <span class="muted small mono">${s.host}</span>`,
72 meta: html`${lapsed ? "Lapsed, waits for review, since" : "Until"}
73 state: lapsed ? "draft" : "open",
74 title: html`${s.account} <span class="muted small mono">${s.host}</span>
75 ${lapsed
76 ? badge("Lapsed", { variant: "outline", icon: "ban" })
77 : soon
78 ? badge("Ends soon", { tone: "attn", icon: "clock" })
outdated

The Ends soon badge relies on tone attn and Lapsed has no glyph. The text carries the state so it is not colour alone, but give Ends soon and Lapsed an icon like Trusted has so all three read the same shape; keep the tone as extra.

79 : badge("Trusted", { variant: "secondary", icon: "check" })}`,
80 meta: html`${lapsed ? "Waits for review since" : "Until"}
7381 ${time(s.until, { date: true })}
74 (${leftWords(s.until, now)})${!lapsed && endsSoon(s, now) ? ", ends soon" : ""} · set by ${s.setBy}`,
82 (${leftWords(s.until, now)}) · set by ${s.setBy}`,
7583 trail: html`<form class="row-fields" method="post" action="/settings/trusted/${s.id}/remove">
7684 ${btn("Remove", { size: "sm", variant: "outline", label: `Remove ${s.account} on ${s.host}` })}
7785 </form>
@@ -83,19 +91,18 @@ export function trustedPage(ctx: Ctx): Response {
8391 </form>`,
8492 });
8593 })}
86 </ul>`,
87 { head: html`${icon("users")}<h2>Trusted sources</h2>`, pad: false },
88 )}
89 ${card(
90 html`<form method="post" action="/settings/trusted" class="stack">
94 </ul>`}
95 </section>
96 <section class="settings-block">
97 <h2>Trust a source</h2>
98 <form method="post" action="/settings/trusted" class="stack">
9199 <label>Account <input class="input" name="account" required maxlength="64" autocomplete="off" aria-describedby="acct-hint" /></label>
92100 <p id="acct-hint" class="muted small">For a downstream forge's proposals write <code>child:name</code>, the name its proposal token was minted for.</p>
93101 <label>Forge <input class="input" name="host" required maxlength="260" placeholder="git.open-platform.sh" autocomplete="off" /></label>
94102 <label>Until <input class="input" name="until" type="date" required /></label>
95103 ${btn("Trust", { icon: "check" })}
96 </form>`,
97 { head: html`${icon("plus")}<h2>Trust a source</h2>` },
98 )}`;
104 </form>
105 </section>`;
99106 notePhase(ctx.request, "render", performance.now() - rendering);
100107 return page({
101108 ctx,
test/trusted-sources.test.ts +102
@@ -239,4 +239,106 @@ describe("the settings page", () => {
239239 servers.stop();
240240 }
241241 });
242
243 test("is headings over rows, each entry's state a badge, and no card (#9792)", async () => {
244 const { serve } = await import("../src/http/server.ts");
245 const { createSession } = await import("../src/core/sessions.ts");
246 const servers = await serve(app);
247 try {
248 const res = await fetch(`${servers.url}/settings/trusted`, {
249 headers: { cookie: `forge_session=${createSession(app.db, alice.id).token}` },
250 });
251 const page = await res.text();
252 const main = page.slice(page.indexOf("<main"), page.indexOf("</main>"));
253 for (const h of ["Fold-in", "Trusted sources", "Trust a source"]) {
254 const at = main.indexOf(`<h2>${h}</h2>`);
255 expect(at).toBeGreaterThan(-1);
256 expect(main.lastIndexOf('<section class="settings-block">', at)).toBeGreaterThan(-1);
257 }
258 for (const boxed of ['class="card', "card-head", "card-body"]) expect(main).not.toContain(boxed);
259 const list = main.slice(main.indexOf('<ul class="list setting-list" id="trusted-list">'));
260 expect(list.length).toBeLessThan(main.length);
261 const first = list.slice(0, list.indexOf("</li>"));

Only the first row and the word Trusted are checked. Add cases that would fail without the change: an entry with a past until renders the Lapsed badge, one within the ends-soon window renders Ends soon, and with no entries the page has the empty sentence and no trusted-list ul.

262 expect(first).toContain('class="row-title"');
263 expect(first).toContain('class="badge"');
264 expect(first).toContain("Trusted");
265 // Same routes, same forms.
266 expect(main).toContain('action="/settings/trusted/stop"');
267 expect(main).toContain('action="/settings/trusted/revert"');
268 expect(main).toMatch(/action="\/settings\/trusted\/[^"]+\/remove"/);
269 expect(main).toContain('action="/settings/trusted"');
270 } finally {
271 servers.stop();
272 }
273 });
274
275 /** The page's `<main>` as `who` sees it on `on`. */
276 async function mainOf(on: App, who: User): Promise<string> {
277 const { serve } = await import("../src/http/server.ts");
278 const { createSession } = await import("../src/core/sessions.ts");
279 const servers = await serve(on);
280 try {
281 const res = await fetch(`${servers.url}/settings/trusted`, {
282 headers: { cookie: `forge_session=${createSession(on.db, who.id).token}` },
283 });
284 const page = await res.text();
285 return page.slice(page.indexOf("<main"), page.indexOf("</main>"));
286 } finally {
287 servers.stop();
288 }
289 }
290
291 /** The `<li>` of the row whose title names `account`. */
292 const rowOf = (main: string, account: string): string => {
293 const at = main.indexOf(`${account} <span`);
294 expect(at).toBeGreaterThan(-1);
295 return main.slice(main.lastIndexOf("<li", at), main.indexOf("</li>", at));
296 };
297
298 test("a lapsed entry and one ending soon each say so with a badge and a glyph (#9792)", async () => {
299 // Written straight to the table: the action refuses a date already past, and a lapse is what time does.
300 const put = app.db.query(
301 "INSERT INTO trusted_source (id, host, account, until, set_by, created_at) VALUES (?1, ?2, ?3, ?4, 'alice', ?5)",
302 );
303 const now = Date.now();
304 put.run("t-lapsed", HOST, "gone-by", new Date(now - 86_400_000).toISOString(), new Date(now).toISOString());
305 put.run("t-soon", HOST, "nearly-out", new Date(now + 86_400_000).toISOString(), new Date(now).toISOString());
306 try {
307 const main = await mainOf(app, alice);
308 const lapsed = rowOf(main, "gone-by");
309 expect(lapsed).toContain('data-state="draft"');
310 expect(lapsed).toMatch(/class="badge"[^>]*>\s*<svg[^]*?Lapsed/);
311 expect(lapsed).toContain("Waits for review since");
312 expect(lapsed).not.toContain("Ends soon");
313 const soon = rowOf(main, "nearly-out");
314 expect(soon).toContain('data-state="open"');
315 expect(soon).toMatch(/class="badge"[^>]*>\s*<svg[^]*?Ends soon/);
316 expect(soon).not.toContain("Lapsed");
317 const steady = rowOf(main, "tdobbertin-espo");
318 expect(steady).toContain('data-state="open"');
319 expect(steady).toMatch(/class="badge"[^>]*>\s*<svg[^]*?Trusted/);
320 expect(steady).not.toContain("Ends soon");
321 } finally {
322 app.db.query("DELETE FROM trusted_source WHERE id IN ('t-lapsed', 't-soon')").run();
323 }
324 });
325
326 test("with no one trusted, one plain sentence and no list (#9792)", async () => {
327 const bare = await mkdtemp(join(tmpdir(), "forge-trusted-empty-"));
328 const other = createApp({ dataDir: bare, port: 0, host: "forge.test" });
329 try {
330 const made = await seedAdmin(other, { username: "bob", email: "[email protected]", password: "correct-horse-battery" });
331 if (!made.ok) throw new Error(made.error);
332 const main = await mainOf(other, made.value);
333 const from = main.indexOf("<h2>Trusted sources</h2>");
334 expect(from).toBeGreaterThan(-1);
335 const block = main.slice(from, main.indexOf("</section>", from));
336 expect(block).toContain("No one is trusted, so every pull waits for review.</p>");
337 expect(block).not.toContain('id="trusted-list"');
338 expect(block).not.toContain("<li");
339 } finally {
340 other.close();
341 await rm(bare, { recursive: true, force: true });
342 }
343 });
242344 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs