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

Settings › Hooks speaks the home page's language #9829

Merged opened this

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

Merged

scout/forge:scout/9762-settings-hooks-speaks-the-home-page-s-la → main 2 commits fork

  • Merged. scout/9762-settings-hooks-speaks-the-home-page-s-la was merged into main as ba0f412.

Review

Sign in to review this change.
3 files changed +141 −112
docs/design/areas/settings.md +2
@@ -116,3 +116,5 @@ title, each with a glyph (Trusted with a check, Ends soon with a clock, Lapsed w
116116 `data-state` is `open`, or `draft` once lapsed, and Remove and Revert landings sit in its trail as
117117 `.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
118118 before. `test/trusted-sources.test.ts` holds the shape.
119
120 Settings › Hooks (`hooksPage()` in `src/web/pages/hooks.ts`, #9762) adds no component class and has no card or table: Webhooks and Add a webhook are each a `<section class="settings-block">` with an `<h2>`. Each hook is a `row()` in `<ul class="list setting-list">` (URL as title; events, state, added date and "unsigned" as meta; Send ping and a confirmed Delete in the trail), with its recent deliveries as nested child rows, Redeliver in the trail of a failed one. With no hook, one plain sentence. The secret is never drawn. Pinned by `test/hooks-rows.test.ts`.
src/web/pages/hooks.ts +66 −112
@@ -15,11 +15,10 @@ import { html, when } from "../html.ts";
1515 import { flashTo } from "../layout.ts";
1616 import {
1717 btn,
18 card,
1918 confirmAction,
2019 dot,
21 empty,
2220 field as formField,
21 row,
2322 time,
2423 } from "../ui.ts";
2524 import { adminForm, settingsPageResponse, timedAdminRepo } from "./settings.ts";
@@ -36,127 +35,82 @@ export function hooksPage(ctx: Ctx): Response {
3635 const rendering = performance.now();
3736 const base = repoBase(repo);
3837
38 const deliveryRow = (hookId: string, d: ReturnType<typeof recentDeliveries>[number]) =>
39 row({
40 title: html`<span class="mono">${d.kind}</span>`,
41 meta: html`${dot(d.state === "ok" ? "success" : d.state === "failed" ? "danger" : "attn")}
42 ${d.state} · ${d.attempts} ${d.attempts === 1 ? "attempt" : "attempts"} · ${d.lastError ?? d.lastStatus ?? "—"}`,
43 trail: when(
44 d.state === "failed",
45 () => html`<form method="post" action="${base}/settings/hooks/${hookId}/deliveries/${d.id}/redeliver">
46 ${btn("Redeliver", { size: "sm" })}
47 </form>`,
48 ),
49 });
50
3951 const list =
4052 hooks.length === 0
41 ? empty({
42 icon: "webhook",
43 title: "No webhooks yet",
44 note: "Every event this repository produces can be POSTed to a URL you control, signed and retried.",
45 })
46 : html`${hooks.map((hook) => {
47 const deliveries = recent.get(hook.id) ?? [];
48 return card(
49 deliveries.length === 0
50 ? html`<p class="muted small">No deliveries yet.</p>`
51 : html`<div class="table-scroll">
52 <table class="table" data-dense>
53 <thead>
54 <tr>
55 <th>Event</th>
56 <th>State</th>
57 <th>Attempts</th>
58 <th>Last status</th>
59 <th><span class="sr-only">Actions</span></th>
60 </tr>
61 </thead>
62 <tbody>
63 ${deliveries.map(
64 (d) =>
65 html`<tr>
66 <td class="mono">${d.kind}</td>
67 <td>
68 ${dot(
69 d.state === "ok"
70 ? "success"
71 : d.state === "failed"
72 ? "danger"
73 : "attn",
74 )}
75 ${d.state}
76 </td>
77 <td>${d.attempts}</td>
78 <td>${d.lastError ?? d.lastStatus ?? "—"}</td>
79 <td>
80 ${when(
81 d.state === "failed",
82 () =>
83 html`<form
84 method="post"
85 action="${base}/settings/hooks/${hook.id}/deliveries/${d.id}/redeliver"
86 >
87 ${btn("Redeliver", { size: "sm" })}
88 </form>`,
89 )}
90 </td>
91 </tr>`,
92 )}
93 </tbody>
94 </table>
95 </div>`,
96 {
97 pad: deliveries.length === 0,
98 head: html`<span class="grow truncate mono" title="${hook.url}"
99 >${hook.url}</span
100 >
101 <form method="post" action="${base}/settings/hooks/${hook.id}/ping">
53 ? html`<p class="small">
54 No webhooks yet. Every event this repository produces can be POSTed to a URL you control, signed and retried.
55 </p>`
56 : html`<ul class="list setting-list">
57 ${hooks.map((hook) => {
58 const deliveries = recent.get(hook.id) ?? [];
59 return row({
60 title: html`<span class="mono">${hook.url}</span>`,
61 titleAttr: hook.url,
62 meta: html`<span class="mono">${hook.kinds}</span> · ${dot(hook.active ? "success" : "attn")}${hook.active
63 ? "active"
64 : "paused"} · added ${time(hook.createdAt)}${when(!hook.hasSecret, () => html` · unsigned`)}${when(
65 deliveries.length === 0,
66 () => html` · No deliveries yet.`,
67 )}`,
68 trail: html`<form method="post" action="${base}/settings/hooks/${hook.id}/ping">
10269 ${btn("Send ping", { size: "sm" })}
10370 </form>
104 <span class="badge" data-variant="secondary"
105 >${hook.kinds}</span
106 >
107 <span class="badge"
108 >${dot(hook.active ? "success" : "attn")}${hook.active
109 ? "active"
110 : "paused"}</span
111 >
11271 ${confirmAction({
11372 action: `${base}/settings/hooks/${hook.id}/delete`,
11473 verb: "Delete webhook",
11574 size: "sm",
11675 })}`,
117 foot: html`<span class="muted small"
118 >Added
119 ${time(hook.createdAt)}${when(
120 !hook.hasSecret,
121 () => html` · unsigned`,
122 )}</span
123 >`,
124 },
125 );
126 })}`;
76 children: deliveries.map((d) => deliveryRow(hook.id, d)),
77 });
78 })}
79 </ul>`;
12780
128 const create = card(
129 html`<form method="post" action="${base}/settings/hooks">
130 ${formField({
131 label: "Payload URL",
132 name: "url",
133 type: "url",
134 required: true,
135 placeholder: "https://example.com/hook",
136 hint: "Every delivery is a POST with a JSON body.",
137 })}
138 ${formField({
139 label: "Events",
140 name: "kinds",
141 value: "*",
142 mono: true,
143 hint: "A space-separated list of kinds; * means everything. e.g. push issue.* pull.merged",
144 })}
145 ${formField({
146 label: "Signing secret",
147 name: "secret",
148 mono: true,
149 hint: "Optional — one is generated for you. Deliveries carry HMAC-SHA256 in X-Forge-Signature-256.",
150 })}
151 <div class="form-actions">
152 ${btn("Add webhook", { variant: "primary" })}
153 </div>
154 </form>`,
155 { head: html`<h2>New webhook</h2>` },
156 );
81 const create = html`<form method="post" action="${base}/settings/hooks">
82 ${formField({
83 label: "Payload URL",
84 name: "url",
85 type: "url",
86 required: true,
87 placeholder: "https://example.com/hook",
88 hint: "Every delivery is a POST with a JSON body.",
89 })}
90 ${formField({
91 label: "Events",
92 name: "kinds",
93 value: "*",
94 mono: true,
95 hint: "A space-separated list of kinds; * means everything. e.g. push issue.* pull.merged",
96 })}
97 ${formField({
98 label: "Signing secret",
99 name: "secret",
100 mono: true,
101 hint: "Optional — one is generated for you. Deliveries carry HMAC-SHA256 in X-Forge-Signature-256.",
102 })}
103 <div class="form-actions">${btn("Add webhook", { variant: "primary" })}</div>
104 </form>`;
157105
158 const body = html`${list}
159 <div class="mt">${create}</div>`;
106 const body = html`<section class="settings-block">
107 <h2>Webhooks</h2>
108 ${list}
109 </section>
110 <section class="settings-block">
111 <h2>Add a webhook</h2>
112 ${create}
113 </section>`;
160114 const response = settingsPageResponse(ctx, repo, "hooks", "Webhooks", body);
161115 notePhase(ctx.request, "render", performance.now() - rendering);
162116 return response;
test/hooks-rows.test.ts added +73
@@ -0,0 +1,73 @@
1 /** Settings › Hooks is headings over rows, not cards (#9762). */
2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { mkdtempSync, rmSync } from "node:fs";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createRepo } from "../src/actions/repos.ts";
7 import { createApp, type App } from "../src/app.ts";
8 import { createUser } from "../src/core/users.ts";
9 import { createWebhook } from "../src/core/webhooks.ts";
10 import { serve, type Servers } from "../src/http/server.ts";
11 import { quietLogs } from "./helpers/globals.ts";
12
13 quietLogs();
14
15 let root = "";
16 let app: App;
17 let servers: Servers;
18 let cookie = "";
19 let repoId = "";
20
21 async function page(): Promise<string> {
22 const html = await (await fetch(`${servers.url}/alice/a/settings/hooks`, { headers: { cookie } })).text();
23 return html.slice(html.indexOf("<main"), html.indexOf("</main>"));
24 }
25
26 beforeAll(async () => {
27 root = mkdtempSync(join(tmpdir(), "forge-hooks-rows-"));
28 app = createApp({ dataDir: root, port: 0 });
29 const u = await createUser(app.db, {
30 origin: "seed", trusted: true, username: "alice", email: "[email protected]",
31 password: "a-good-passphrase", adminIfFirst: true,
32 });
33 if (!u.ok) throw new Error(u.error);
34 const r = await createRepo(app, u.value, { name: "a", visibility: "public" });
35 if (!r.ok) throw new Error("repo");
36 repoId = r.value.id;
37 servers = await serve(app);
38 const signin = await fetch(`${servers.url}/signin`, {
39 method: "POST",
40 headers: { origin: servers.url },
41 body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }),
42 redirect: "manual",
43 });
44 cookie = (signin.headers.get("set-cookie") ?? "").split(";")[0]!;
45 });
46 afterAll(() => {
47 servers.stop();
48 app.close();
49 rmSync(root, { recursive: true, force: true });
50 });
51
52 test("with no hook: two headed blocks, one plain sentence, the add form, no box", async () => {
53 const main = await page();
54 for (const h of ["Webhooks", "Add a webhook"]) expect(main).toContain(`<h2>${h}</h2>`);
55 expect(main).toContain("No webhooks yet.");
56 expect(main).toContain('action="/alice/a/settings/hooks"');
57 for (const boxed of ['class="card', "card-head", "card-body", 'class="empty', "<table"]) expect(main).not.toContain(boxed);
58 });
59
60 test("a hook is a row with its URL, events, ping and a confirmed delete; the secret is not shown", async () => {
61 const made = createWebhook(app.db, { repoId, url: "https://a.example/h", secret: "s3cret-value", kinds: "push" });
62 if (!made.ok) throw new Error("hook");
63 const main = await page();
64 const block = main.slice(main.indexOf("<h2>Webhooks</h2>"), main.indexOf("<h2>Add a webhook</h2>"));
65 expect(block).toContain('<ul class="list setting-list">');
66 expect(block).toContain("https://a.example/h");
67 expect(block).toContain("push");
68 expect(block).toContain("No deliveries yet.");
69 expect(block).toContain(`/alice/a/settings/hooks/${made.value.webhook.id}/ping`);
70 expect(block).toContain(`/-/confirm?to=${encodeURIComponent(`/alice/a/settings/hooks/${made.value.webhook.id}/delete`)}`);
71 expect(main).not.toContain("s3cret-value");
72 for (const boxed of ['class="card', "card-head", "<table"]) expect(main).not.toContain(boxed);
73 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs