Public: anyone can read this; the crew can change it.
The webhooks list is keyboard-first: j/k move between rows, enter opens one #10645
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/10429-the-webhooks-list-is-keyboard-first-j-k → main 2 commits fork
2 files
changed +56
−0
src/web/pages/hooks.ts +1
| @@ -84,6 +84,7 @@ export function hooksPage(ctx: Ctx): Response { | ||
| 84 | 84 | ${hooks.map((hook) => { |
| 85 | 85 | const deliveries = recent.get(hook.id) ?? []; |
| 86 | 86 | return row({ |
| 87 | nav: true, | |
| 87 | 88 | title: html`<span class="mono">${hook.url}</span>`, |
| 88 | 89 | titleAttr: hook.url, |
| 89 | 90 | meta: html`<span class="mono">${hook.kinds}</span> · ${dot(hook.active ? "success" : "attn")}${hook.active |
test/keys-hooks.test.ts added +55
| @@ -0,0 +1,55 @@ | ||
| 1 | /** The webhooks list joins the shell's j/k row traversal (#10429): one data-nav per hook, none on the empty page. */ | |
| 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 { createRepo } from "../src/actions/repos.ts"; | |
| 7 | import { createApp, type App } from "../src/app.ts"; | |
| 8 | import { createSession } from "../src/core/sessions.ts"; | |
| 9 | import { createUser } from "../src/core/users.ts"; | |
| 10 | import { createWebhook } from "../src/core/webhooks.ts"; | |
| 11 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 12 | ||
| 13 | let dataDir: string; | |
| 14 | let app: App; | |
| 15 | let servers: Servers; | |
| 16 | let cookie = ""; | |
| 17 | ||
| 18 | beforeAll(async () => { | |
| 19 | dataDir = await mkdtemp(join(tmpdir(), "forge-keys-hooks-")); | |
| 20 | app = createApp({ dataDir, port: 0 }); | |
| 21 | servers = await serve(app); | |
| 22 | const made = await createUser(app.db, { | |
| 23 | origin: "seed", trusted: true, username: "alice", email: "[email protected]", | |
| 24 | password: "a-good-passphrase", adminIfFirst: true, | |
| 25 | }); | |
| 26 | if (!made.ok) throw new Error(String(made.error)); | |
| 27 | const alice = made.value; | |
| 28 | cookie = `forge_session=${createSession(app.db, alice.id).token}; forge_mode=dev`; | |
| 29 | const none = await createRepo(app, alice, { name: "none", visibility: "public" }); | |
| 30 | const some = await createRepo(app, alice, { name: "some", visibility: "public" }); | |
| 31 | if (!none.ok || !some.ok) throw new Error("repo"); | |
| 32 | for (const n of [1, 2]) createWebhook(app.db, { repoId: some.value.id, url: `https://h${n}.example/hook` }); | |
| 33 | }, 60_000); | |
| 34 | ||
| 35 | afterAll(async () => { | |
| 36 | await servers.stop(); | |
| 37 | app.close(); | |
| 38 | await rm(dataDir, { recursive: true, force: true }); | |
| 39 | }); | |
| 40 | ||
| 41 | const get = async (name: string) => | |
| 42 | (await fetch(`${servers.url}/alice/${name}/settings/hooks`, { headers: { cookie } })).text(); | |
| 43 | const NAV = /class="row"[^>]*data-nav tabindex="-1"/g; | |
| 44 | ||
| 45 | test("each webhook is one data-nav row", async () => { | |
| 46 | const text = await get("some"); | |
| 47 | expect(text).toContain("https://h2.example/hook"); | |
| 48 | expect(text.match(NAV)?.length).toBe(2); | |
| 49 | }); | |
| 50 | ||
| 51 | test("the empty webhooks page draws no data-nav row", async () => { | |
| 52 | const text = await get("none"); | |
| 53 | expect(text).toContain("No webhooks yet"); | |
| 54 | expect(text).not.toMatch(NAV); | |
| 55 | }); |