Public: anyone can read this; the crew can change it.
/system rows are keyboard-first: j/k move through its panels, enter drills in #10350
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10340-system-rows-are-keyboard-first-j-k-move → main 2 commits fork
2 files
changed +67
−2
src/web/pages/system.ts +10 −2
| @@ -137,8 +137,16 @@ export function cachesSection(rows: readonly CacheRow[]): Html { | ||
| 137 | 137 | export function featuresSection(rows: readonly FeatureHealth[]): Html { |
| 138 | 138 | if (rows.length === 0) return html``; |
| 139 | 139 | return html`<section class="settings-block" id="sys-features"><h2>Features from apps</h2> |
| 140 | <ul class="list" aria-label="Absorbed features and their health">${rows.map((r) => html`<li data-feature="${r.slug}" data-healthy="${String(r.healthy)}"><strong>${r.name}</strong> ${r.healthy ? html`<span class="small muted">${r.state}</span>` : badge(r.state, { tone: "danger" })} | |
| 141 | <span class="small muted mono">${[r.commit, r.since].filter((x) => x !== null).join(" · ")}</span>${r.problem === null ? null : html` <span class="small muted">${r.problem}</span>`}</li>`)}</ul> | |
| 140 | <ul class="list" aria-label="Absorbed features and their health">${rows.map((r) => | |
| 141 | row({ | |
| 142 | href: `/${r.repo}`, | |
| 143 | nav: true, | |
| 144 | title: r.name, | |
| 145 | meta: html`${r.healthy ? html`<span class="small muted">${r.state}</span>` : badge(r.state, { tone: "danger" })} | |
| 146 | <span class="small muted mono">${[r.commit, r.since].filter((x) => x !== null).join(" · ")}</span>${r.problem === null ? null : html` <span class="small muted">${r.problem}</span>`}`, | |
| 147 | data: { feature: r.slug, healthy: String(r.healthy) }, | |
| 148 | }), | |
| 149 | )}</ul> | |
| 142 | 150 | <p class="small"><a href="/settings/features">Settings › Features</a></p></section>`; |
| 143 | 151 | } |
| 144 | 152 |
test/keys-system.test.ts added +57
| @@ -0,0 +1,57 @@ | ||
| 1 | /** /system's feature rows join j/k traversal (#10340): one `data-nav` per row, linking to the app's own page; none when empty. */ | |
| 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 } from "../src/app.ts"; | |
| 7 | import { createRepo } from "../src/actions/repos.ts"; | |
| 8 | import { createApp as insertApp } from "../src/core/apps.ts"; | |
| 9 | import { absorb } from "../src/core/features.ts"; | |
| 10 | import { createToken } from "../src/core/tokens.ts"; | |
| 11 | import { createUser } from "../src/core/users.ts"; | |
| 12 | import { serve } from "../src/http/server.ts"; | |
| 13 | import { quietLogs } from "./helpers/globals.ts"; | |
| 14 | ||
| 15 | quietLogs(); | |
| 16 | ||
| 17 | let dir: string; | |
| 18 | let app: ReturnType<typeof createApp>; | |
| 19 | let servers: Awaited<ReturnType<typeof serve>>; | |
| 20 | let token = ""; | |
| 21 | let ada: Awaited<ReturnType<typeof createUser>> & { ok: true }; | |
| 22 | ||
| 23 | const get = async () => (await fetch(`${servers.url}/system`, { headers: { authorization: `Bearer ${token}` } })).text(); | |
| 24 | const navs = (html: string) => html.match(/<a [^>]*data-nav[^>]*>/g) ?? []; | |
| 25 | ||
| 26 | beforeAll(async () => { | |
| 27 | dir = await mkdtemp(join(tmpdir(), "forge-keys-system-")); | |
| 28 | app = createApp({ dataDir: dir, port: 0 }); | |
| 29 | servers = await serve(app); | |
| 30 | const r = await createUser(app.db, { origin: "seed", trusted: true, username: "ada", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true }); | |
| 31 | if (!r.ok) throw new Error(r.error); | |
| 32 | ada = r as typeof ada; | |
| 33 | token = createToken(app.db, r.value.id, "t", "admin").token; | |
| 34 | }); | |
| 35 | afterAll(async () => { | |
| 36 | await servers.stop(); | |
| 37 | await rm(dir, { recursive: true, force: true }); | |
| 38 | }); | |
| 39 | ||
| 40 | test("a page with no features has no data-nav", async () => { | |
| 41 | expect(await get()).not.toContain("data-nav"); | |
| 42 | }); | |
| 43 | ||
| 44 | test("each feature row is one data-nav linking to its own page", async () => { | |
| 45 | for (const name of ["one", "two"]) { | |
| 46 | const repo = await createRepo(app, ada.value, { name, visibility: "public", description: "" }); | |
| 47 | if (!repo.ok) throw new Error(String(repo.error)); | |
| 48 | const a = insertApp(app.db, { repoId: repo.value.id, slug: name, access: "public" }); | |
| 49 | if (!a.ok) throw new Error(a.error); | |
| 50 | absorb(app.db, a.value.id, ada.value.id, name, "/"); | |
| 51 | } | |
| 52 | const found = navs(await get()); | |
| 53 | expect(found).toHaveLength(2); | |
| 54 | expect(found.some((a) => a.includes('href="/ada/one"'))).toBe(true); | |
| 55 | expect(found.some((a) => a.includes('href="/ada/two"'))).toBe(true); | |
| 56 | expect(found.every((a) => a.includes('tabindex="-1"'))).toBe(true); | |
| 57 | }); |