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

j/k, enter, esc and ? on features, with test/keys-<page>.test.ts #10811

Merged opened this

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

Merged

scout/forge:scout/10799-j-k-enter-esc-and-on-features-with-test → main 2 commits fork

  • Merged. scout/10799-j-k-enter-esc-and-on-features-with-test was merged into main as 6c0c5cf.

Review

Sign in to review this change.
2 files changed +102 −1
src/web/pages/features.ts +3 −1
@@ -67,6 +67,8 @@ export function provenanceRows(p: FeatureView["provenance"]) {
6767 function featureRow(f: FeatureView) {
6868 const base = `/settings/features/${encodeURIComponent(f.slug)}`;
6969 return row({
70 href: base,
71 nav: true,
7072 title: html`${f.name} <span class="muted small mono">${f.repo}</span>
7173 ${f.absorbed ? badge("absorbed", { tone: "success" }) : null}
7274 ${f.pending !== null ? badge("declaration changed: review", { tone: "attn" }) : null}`,
@@ -81,7 +83,7 @@ function featureRow(f: FeatureView) {
8183 : html`${f.summary ?? ""}${when(f.summary !== null, () => html`<br />`)}${f.settings.length} setting${f.settings.length === 1 ? "" : "s"}${f.absorbed
8284 ? html` · absorbed by ${f.absorbedBy ?? "someone"}`
8385 : null}`}<br /><span id="sw-${f.slug}">${switchNote(f)}</span>`,
84 trail: html`${when(f.pending !== null, () => html`<form class="row-fields" method="post" action="${base}/accept">
86 after: html`${when(f.pending !== null, () => html`<form class="row-fields" method="post" action="${base}/accept">
8587 <input type="hidden" name="name" value="${f.pending?.name ?? ""}" />
8688 <input type="hidden" name="page" value="${f.pending?.page ?? ""}" />
8789 ${btn("Accept", { size: "sm", label: `Accept ${f.pending?.name ?? ""} as ${f.name}'s menu entry` })}
test/keys-features.test.ts added +99
@@ -0,0 +1,99 @@
1 /**
2 * The real /settings/features page and the shell's keys. Each declared feature
3 * is a j/k row whose link opens its own settings page; its Absorb / Settings /
4 * Accept forms sit beside the link (the row's `after`), not inside it, so they
5 * are not rows and Enter never submits one. The settings sections share the
6 * same walk, as on every settings page.
7 */
8 import { afterAll, beforeAll, expect, test } from "bun:test";
9 import { mkdtemp, rm } from "node:fs/promises";
10 import { tmpdir } from "node:os";
11 import { join } from "node:path";
12 import { createApp, type App } from "../src/app.ts";
13 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
14 import { createApp as insertApp } from "../src/core/apps.ts";
15 import { createUser } from "../src/core/users.ts";
16 import { serve, type Servers } from "../src/http/server.ts";
17 import { quietLogs } from "./helpers/globals.ts";
18 import { mainOf, shellPage } from "./helpers/shell-page.ts";
19
20 quietLogs();
21
22 let dir = "";
23 let app: App;
24 let servers: Servers;
25 let html = "";
26 let detail = "";
27
28 beforeAll(async () => {
29 dir = await mkdtemp(join(tmpdir(), "forge-keys-features-"));
30 app = createApp({ dataDir: dir, port: 0, host: "forge.test" });
31 servers = await serve(app);
32 const made = await createUser(app.db, {
33 origin: "seed", trusted: true, username: "ada", email: "[email protected]",
34 password: "a-good-passphrase", adminIfFirst: true,
35 });
36 if (!made.ok) throw new Error(made.error);
37 const repo = await createRepo(app, made.value, { name: "radar", visibility: "private", description: "" });
38 if (!repo.ok) throw new Error(String(repo.error));
39 const forgeJson = JSON.stringify({ app: { run: "bun run x.ts" }, feature: { name: "Radar", page: "/", settings: {} } });
40 await commitToBranch(app, made.value, repo.value, { message: "feature", files: [{ path: "forge.json", content: forgeJson }] });
41 insertApp(app.db, { repoId: repo.value.id, slug: "radar", access: "private" });
42 const signed = await fetch(`${servers.url}/signin`, {
43 method: "POST",
44 body: new URLSearchParams({ username: "ada", password: "a-good-passphrase" }),
45 redirect: "manual",
46 });
47 const cookie = (signed.headers.get("set-cookie") ?? "").split(";")[0]!;
48 html = mainOf(await (await fetch(`${servers.url}/settings/features`, { headers: { cookie } })).text());
49 detail = mainOf(await (await fetch(`${servers.url}/settings/features/radar`, { headers: { cookie } })).text());
50 }, 60_000);
51 afterAll(async () => {
52 servers?.stop();
53 app?.close();
54 if (dir) await rm(dir, { recursive: true, force: true });
55 });
56
57 test("a feature is one row linking its page; its forms are beside the link, not rows", () => {
58 expect(html).toContain("Radar");
59 const row = html.slice(html.indexOf('href="/settings/features/radar"') - 3);
60 expect(row.slice(0, 2)).toBe("<a");
61 expect(row.slice(0, row.indexOf("</a>"))).toContain("data-nav");
62 expect(row.slice(0, row.indexOf("</a>"))).not.toContain("<form");
63 expect(row).toContain('action="/settings/features/radar/absorb"');
64 });
65
66 test("j/k reach the feature row with the focus marker, Enter opens it, fields keep their keys, `?` helps", async () => {
67 const p = await shellPage("/settings/features", html + "<input id=f>");
68 const rows = [...p.doc.querySelectorAll("[data-nav]")] as any[];
69 const at = rows.findIndex((r) => r.getAttribute("href") === "/settings/features/radar");
70 expect(at).toBeGreaterThan(0);
71 expect(rows[at].getAttribute("tabindex")).toBe("-1");
72
73 for (let i = 0; i < rows.length && p.doc.activeElement !== rows[at]; i++) p.key("j");
74 expect(p.doc.activeElement).toBe(rows[at]);
75 p.key("k");
76 expect(p.doc.activeElement).toBe(rows[at - 1]);
77 p.key("j");
78 p.key("Enter");
79 expect(p.clicks).toEqual(["/settings/features/radar"]);
80 const stored = JSON.parse(p.win.sessionStorage.getItem("nav-from")!);
81 expect(stored).toMatchObject({ list: "/settings/features", href: "/settings/features/radar" });
82
83 const f = p.doc.getElementById("f") as any;
84 f.focus();
85 const before = p.clicks.length;
86 p.key("j", f);
87 p.key("Enter", f);
88 expect(p.doc.activeElement).toBe(f);
89 expect(p.clicks.length).toBe(before);
90
91 expect(p.key("?").defaultPrevented).toBe(true);
92 });
93
94 test("Esc on the feature's page: it carries the settings sections, which are rows, so it stays put", async () => {
95 const stored = JSON.stringify({ list: "/settings/features", to: "/settings/features/radar", href: "/settings/features/radar" });
96 const back = await shellPage("/settings/features/radar", detail, { "nav-from": stored });
97 back.key("Escape");
98 expect(back.assigned).toEqual([]);
99 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs